From c1c7fc7157c83fa11f28d50743597f496f6dfa2d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:10:00 +0700 Subject: [PATCH 001/272] feat(studio): add 4I+4V 9-2LE reference SCL template --- .../arstack_4i4v_9-2le_reference.scd | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 apps/arstack_studio/templates/arstack_4i4v_9-2le_reference.scd diff --git a/apps/arstack_studio/templates/arstack_4i4v_9-2le_reference.scd b/apps/arstack_studio/templates/arstack_4i4v_9-2le_reference.scd new file mode 100644 index 000000000..d01749cc7 --- /dev/null +++ b/apps/arstack_studio/templates/arstack_4i4v_9-2le_reference.scd @@ -0,0 +1,72 @@ + +
+ + + + +
+

0

+

4

+

01-0C-CD-04-00-01

+

4000

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From aa84966eeaca9f28a1f45da33a310d097888d250 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:10:18 +0700 Subject: [PATCH 002/272] feat(studio): expose built-in 4I+4V reference profile --- apps/arstack_studio/src/SclProfileModel.hpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index a01a7837f..52f1050a2 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -21,6 +21,7 @@ class SclProfileModel : public QAbstractListModel { Q_PROPERTY(int selectedIndex READ selectedIndex WRITE selectStream NOTIFY selectedIndexChanged) Q_PROPERTY(QVariantMap selectedProfile READ selectedProfile NOTIFY selectedProfileChanged) Q_PROPERTY(bool hasProfiles READ hasProfiles NOTIFY sourceChanged) + Q_PROPERTY(bool referenceTemplateActive READ referenceTemplateActive NOTIFY sourceChanged) public: enum Roles { @@ -53,8 +54,10 @@ class SclProfileModel : public QAbstractListModel { [[nodiscard]] int selectedIndex() const noexcept; [[nodiscard]] QVariantMap selectedProfile() const; [[nodiscard]] bool hasProfiles() const noexcept; + [[nodiscard]] bool referenceTemplateActive() const noexcept; Q_INVOKABLE bool loadFile(const QUrl& fileUrl); + Q_INVOKABLE bool loadReferenceTemplate(); Q_INVOKABLE void clear(); Q_INVOKABLE void selectStream(int row); Q_INVOKABLE bool confirmCounterModulus(int modulus); @@ -78,6 +81,7 @@ class SclProfileModel : public QAbstractListModel { std::optional profile; }; + void installDocument(ar::iec61850::scl::SclDocument document, bool referenceTemplate); void rebuildRows(); [[nodiscard]] QVariantMap profileToVariantMap( const ar::iec61850::sampled_values::SvPublisherProfile& profile) const; @@ -87,4 +91,5 @@ class SclProfileModel : public QAbstractListModel { std::vector rows_; int selectedIndex_{-1}; QString fatalError_; -}; \ No newline at end of file + bool referenceTemplateActive_{false}; +}; From ef984f43b1d52834db24c9f9b7b8ae32fe7aafe7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:10:55 +0700 Subject: [PATCH 003/272] feat(studio): load bundled 4I+4V reference through canonical SCL compiler --- apps/arstack_studio/src/SclProfileModel.cpp | 80 ++++++++++++++------- 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index f20cb03ed..c6c097709 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -5,12 +5,14 @@ #include "ariec61850/sampled_values/esp32p4_profile_support.hpp" #include "ariec61850/scl/parser.hpp" +#include #include #include #include #include #include +#include namespace { using ar::iec61850::sampled_values::Esp32P4SvProfileSupport; @@ -22,6 +24,10 @@ using ar::iec61850::sampled_values::SvSampleMode; using ar::iec61850::sampled_values::classify_esp32p4_sv_profile; using ar::iec61850::sampled_values::esp32p4_sv_profile_support_name; +constexpr std::uint16_t kReferenceCounterModulus = 4000; +constexpr auto kReferenceResource = ":/arstack/templates/arstack_4i4v_9-2le_reference.scd"; +constexpr auto kReferenceSourceName = "ARStack 4I+4V 9-2LE Reference"; + QString qstring(const std::string& value) { return QString::fromStdString(value); } @@ -134,6 +140,11 @@ QString SclProfileModel::sourceName() const { QString SclProfileModel::documentStatus() const { if (!document_.has_value()) return QStringLiteral("No engineering file loaded"); + if (referenceTemplateActive_) { + return QStringLiteral("4I+4V 9-2LE reference · %1 resolved SV stream%2") + .arg(static_cast(rows_.size())) + .arg(rows_.size() == 1U ? QString{} : QStringLiteral("s")); + } return QStringLiteral("%1 resolved SV stream%2") .arg(static_cast(rows_.size())) .arg(rows_.size() == 1U ? QString{} : QStringLiteral("s")); @@ -151,22 +162,24 @@ QStringList SclProfileModel::documentWarnings() const { QString SclProfileModel::fatalError() const { return fatalError_; } int SclProfileModel::selectedIndex() const noexcept { return selectedIndex_; } bool SclProfileModel::hasProfiles() const noexcept { return !rows_.empty(); } +bool SclProfileModel::referenceTemplateActive() const noexcept { return referenceTemplateActive_; } -QVariantMap SclProfileModel::selectedProfile() const { - if (selectedIndex_ < 0 || selectedIndex_ >= rowCount()) return {}; - const auto& row = rows_[static_cast(selectedIndex_)]; - QVariantMap out; - out.insert(QStringLiteral("compatibilityClass"), row.compatibilityClass); - out.insert(QStringLiteral("deviceSupport"), row.deviceSupport); - out.insert(QStringLiteral("warnings"), row.warnings); - out.insert(QStringLiteral("errors"), row.errors); - if (row.profile.has_value()) { - const auto profileMap = profileToVariantMap(*row.profile); - for (auto it = profileMap.cbegin(); it != profileMap.cend(); ++it) { - out.insert(it.key(), it.value()); - } - } - return out; +void SclProfileModel::installDocument( + ar::iec61850::scl::SclDocument document, + const bool referenceTemplate) { + beginResetModel(); + document_ = std::move(document); + confirmedCounterModulus_ = referenceTemplate + ? std::optional{kReferenceCounterModulus} + : std::nullopt; + rows_.clear(); + selectedIndex_ = -1; + fatalError_.clear(); + referenceTemplateActive_ = referenceTemplate; + endResetModel(); + rebuildRows(); + emit fatalErrorChanged(); + emit sourceChanged(); } bool SclProfileModel::loadFile(const QUrl& fileUrl) { @@ -180,16 +193,7 @@ bool SclProfileModel::loadFile(const QUrl& fileUrl) { try { auto loaded = ar::iec61850::scl::SclParser{}.load( std::filesystem::path{localPath.toStdWString()}); - beginResetModel(); - document_ = std::move(loaded); - confirmedCounterModulus_.reset(); - rows_.clear(); - selectedIndex_ = -1; - fatalError_.clear(); - endResetModel(); - rebuildRows(); - emit fatalErrorChanged(); - emit sourceChanged(); + installDocument(std::move(loaded), false); return true; } catch (const std::exception& error) { clear(); @@ -199,6 +203,31 @@ bool SclProfileModel::loadFile(const QUrl& fileUrl) { } } +bool SclProfileModel::loadReferenceTemplate() { + QFile file(QString::fromLatin1(kReferenceResource)); + if (!file.open(QIODevice::ReadOnly)) { + clear(); + fatalError_ = QStringLiteral("Bundled 4I+4V reference template is unavailable."); + emit fatalErrorChanged(); + return false; + } + + const QByteArray xml = file.readAll(); + try { + auto loaded = ar::iec61850::scl::SclParser{}.parse( + std::string_view{xml.constData(), static_cast(xml.size())}, + kReferenceSourceName); + installDocument(std::move(loaded), true); + return true; + } catch (const std::exception& error) { + clear(); + fatalError_ = QStringLiteral("Built-in reference profile failed validation: %1") + .arg(QString::fromUtf8(error.what())); + emit fatalErrorChanged(); + return false; + } +} + void SclProfileModel::clear() { beginResetModel(); document_.reset(); @@ -206,6 +235,7 @@ void SclProfileModel::clear() { rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); + referenceTemplateActive_ = false; endResetModel(); emit selectedIndexChanged(); emit selectedProfileChanged(); From 89b71734796209dba84a6c2b65f78863397e5d48 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:11:06 +0700 Subject: [PATCH 004/272] build(studio): bundle reference SCL template --- apps/arstack_studio/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 96119a3e5..8a2f7e53c 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -71,6 +71,12 @@ qt_add_qml_module(arstack_studio assets/lucide/clock-3.svg ) +qt_add_resources(arstack_studio "reference_templates" + PREFIX "/arstack" + FILES + templates/arstack_4i4v_9-2le_reference.scd +) + target_link_libraries(arstack_studio PRIVATE Qt6::Core Qt6::Gui From c20123ed932bfc685bc5b1898cf1b3dc18f48718 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:11:21 +0700 Subject: [PATCH 005/272] test(studio): add reference template self-check --- apps/arstack_studio/src/main.cpp | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index d79ea73fd..994cde9da 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -3,11 +3,59 @@ #include "DeviceController.hpp" #include "SclProfileModel.hpp" +#include +#include #include #include #include +#include +#include + +namespace { +bool hasArgument(const int argc, char* argv[], const std::string_view wanted) { + for (int i = 1; i < argc; ++i) { + if (argv[i] != nullptr && std::string_view{argv[i]} == wanted) return true; + } + return false; +} + +int checkReferenceTemplate(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + SclProfileModel profiles; + if (!profiles.loadReferenceTemplate()) { + qCritical().noquote() << profiles.fatalError(); + return 2; + } + + const auto profile = profiles.selectedProfile(); + const bool valid = + profiles.referenceTemplateActive() && + profiles.rowCount() == 1 && + profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && + profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready") && + profile.value(QStringLiteral("svId")).toString() == QStringLiteral("ARSTACK_SV01") && + profile.value(QStringLiteral("publisherRate")).toULongLong() == 4000ULL && + profile.value(QStringLiteral("counterModulus")).toUInt() == 4000U && + profile.value(QStringLiteral("payloadBytes")).toULongLong() == 64ULL && + profile.value(QStringLiteral("channelLeafCount")).toULongLong() == 16ULL; + + if (!valid) { + qCritical().noquote() << "4I+4V reference template regression:" << profile; + return 3; + } + + qInfo().noquote() + << "ARStack 4I+4V 9-2LE reference template: PASS · Class A · ready · 4000 fps · 64 B"; + return 0; +} +} // namespace + int main(int argc, char* argv[]) { + if (hasArgument(argc, argv, "--check-reference-template")) { + return checkReferenceTemplate(argc, argv); + } + QGuiApplication app(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); From 0c7bb33bb00dfa42e1ceea5a157435a3462a3a13 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:11:52 +0700 Subject: [PATCH 006/272] feat(studio): add one-click 4I+4V reference quick start --- apps/arstack_studio/qml/WorkflowBar.qml | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 3bafeae2d..16549eebf 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -95,6 +95,28 @@ SurfacePanel { Layout.rightMargin: 10 spacing: 6 + RibbonAction { + text: ribbon.profiles.referenceTemplateActive ? "4I+4V Ready" : "4I+4V Quick Start" + iconSource: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") + tone: ribbon.profiles.referenceTemplateActive ? "accent" : "neutral" + enabled: !ribbon.device.running && !ribbon.device.profileDeploying + toolTipText: "Load the bundled ARStack 4I+4V 9-2LE reference profile at 4000 fps" + onClicked: { + if (ribbon.profiles.loadReferenceTemplate()) { + ribbon.controller.profileDirty = true + ribbon.controller.showMessage( + "4I+4V 9-2LE reference loaded · Class A · 4000 fps · smpCnt 0..3999.", + false) + } else { + ribbon.controller.showMessage( + ribbon.profiles.fatalError || "Unable to load the bundled reference profile.", + true) + } + } + } + + RibbonDivider {} + RibbonAction { text: "Balanced" iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") @@ -239,6 +261,20 @@ SurfacePanel { iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") onClicked: ribbon.controller.openEngineeringFile() } + RibbonAction { + text: "4I+4V Reference" + iconSource: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") + tone: ribbon.profiles.referenceTemplateActive ? "accent" : "neutral" + enabled: !ribbon.device.running && !ribbon.device.profileDeploying + onClicked: { + if (ribbon.profiles.loadReferenceTemplate()) { + ribbon.controller.profileDirty = true + ribbon.controller.showMessage("ARStack 4I+4V reference engineering profile loaded.", false) + } else { + ribbon.controller.showMessage(ribbon.profiles.fatalError, true) + } + } + } RibbonAction { text: "Detect device" iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") From 0e418ba3efba25476a82d3e7472960b5f3e1e5e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:12:10 +0700 Subject: [PATCH 007/272] ci(studio): gate bundled 4I+4V reference profile --- .github/workflows/arstack-studio-qt.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 0a30c6f70..654c88575 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -75,6 +75,13 @@ jobs: cmake --build build-arstack-studio --target arstack_studio --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log + - name: 4I+4V reference template regression + shell: bash + run: | + set -o pipefail + ./build-arstack-studio/arstack_studio --check-reference-template \ + 2>&1 | tee /tmp/arstack-studio-reference-template.log + - name: Headless QML launch smoke shell: bash run: | @@ -97,6 +104,7 @@ jobs: path: | /tmp/arstack-studio-configure.log /tmp/arstack-studio-build.log + /tmp/arstack-studio-reference-template.log /tmp/arstack-studio-launch.log if-no-files-found: ignore retention-days: 7 From ee09877c0f6640cfc5ce0e9b82b9eda1a3e60a01 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:13:46 +0700 Subject: [PATCH 008/272] fix(studio): retain selected profile mapping after template refactor --- apps/arstack_studio/src/SclProfileModel.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index c6c097709..2f335089c 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -164,6 +164,23 @@ int SclProfileModel::selectedIndex() const noexcept { return selectedIndex_; } bool SclProfileModel::hasProfiles() const noexcept { return !rows_.empty(); } bool SclProfileModel::referenceTemplateActive() const noexcept { return referenceTemplateActive_; } +QVariantMap SclProfileModel::selectedProfile() const { + if (selectedIndex_ < 0 || selectedIndex_ >= rowCount()) return {}; + const auto& row = rows_[static_cast(selectedIndex_)]; + QVariantMap out; + out.insert(QStringLiteral("compatibilityClass"), row.compatibilityClass); + out.insert(QStringLiteral("deviceSupport"), row.deviceSupport); + out.insert(QStringLiteral("warnings"), row.warnings); + out.insert(QStringLiteral("errors"), row.errors); + if (row.profile.has_value()) { + const auto profileMap = profileToVariantMap(*row.profile); + for (auto it = profileMap.cbegin(); it != profileMap.cend(); ++it) { + out.insert(it.key(), it.value()); + } + } + return out; +} + void SclProfileModel::installDocument( ar::iec61850::scl::SclDocument document, const bool referenceTemplate) { From b41917e826add4c44f5605787cddc3c5e1a66184 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:14:28 +0700 Subject: [PATCH 009/272] docs(studio): define 4I+4V reference template contract --- apps/arstack_studio/templates/README.md | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/arstack_studio/templates/README.md diff --git a/apps/arstack_studio/templates/README.md b/apps/arstack_studio/templates/README.md new file mode 100644 index 000000000..27d6c4424 --- /dev/null +++ b/apps/arstack_studio/templates/README.md @@ -0,0 +1,28 @@ +# ARStack Studio reference templates + +The first public-release target intentionally ships one built-in Sampled Values reference profile: + +- **4 current + 4 voltage** (`Ia, Ib, Ic, In, Ua, Ub, Uc, Un`) +- `INT32 value + Quality` for each channel +- 16 ordered FCDA leaves / 64-byte sample payload +- one ASDU +- 4000 samples/s (`SmpPerSec`) +- ARStack-owned `svID`, IED and engineering identifiers +- multicast destination `01-0C-CD-04-00-01` +- APPID `0x4000` +- VLAN VID 0 / PCP 4 +- configuration revision 1 + +`arstack_4i4v_9-2le_reference.scd` is a convenience engineering source, not a second runtime profile implementation. ARStack Studio loads it through the same `SclParser -> SvPublisherProfileCompiler -> ESP32-P4 support classifier` path used for external SCL/CID/SCD/IID files. + +## Counter policy + +Generic SCL does not universally prove the `smpCnt` wrap policy. The bundled ARStack reference profile is different: its product contract explicitly defines a 4000-count cycle (`0..3999`). `SclProfileModel::loadReferenceTemplate()` therefore supplies modulus 4000 as trusted application-owned profile context so the reference stream resolves to Class A. + +This exception is **only** for the bundled reference profile. External engineering files retain the normal Class A/B/C behavior and require explicit confirmation when their counter policy is not independently established. + +## Release boundary + +The current ESP32-P4 firmware deployment bridge remains intentionally limited to this physically proven 4I+4V wire layout. Adding more reference templates must not imply firmware support for additional layouts; each future template requires parser/compiler regression, firmware capacity/support classification, and retained on-wire validation. + +The reference template is an engineering/test convenience and is not a formal IEC 61850 conformance or certification claim. From 103a9d38660dd0c4e80e2351c27c5f549353a7c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:15:58 +0700 Subject: [PATCH 010/272] docs(studio): lock P0 public-release boundary to 4I+4V --- docs/SMV_STUDIO_P0_RELEASE.md | 90 +++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/SMV_STUDIO_P0_RELEASE.md diff --git a/docs/SMV_STUDIO_P0_RELEASE.md b/docs/SMV_STUDIO_P0_RELEASE.md new file mode 100644 index 000000000..b53bea211 --- /dev/null +++ b/docs/SMV_STUDIO_P0_RELEASE.md @@ -0,0 +1,90 @@ +# ARStack Studio P0 public-release contract + +## Product target + +The first public ARStack Studio release is intentionally bounded to one professional workflow: + +> **IEC 61850 Sampled Values Injector / Generator — 4I + 4V** + +The release must make the already proven ESP32-P4 publisher usable as a clean desktop instrument without requiring the operator to install or understand the development toolchain. + +## P0 profile boundary + +- 4 current channels: `Ia`, `Ib`, `Ic`, `In` +- 4 voltage channels: `Ua`, `Ub`, `Uc`, `Un` +- one `INT32 value + Quality` pair per channel +- 16 ordered FCDA leaves +- 64-byte sample payload +- one ASDU +- 4000 samples/s reference profile +- sample-counter cycle `0..3999` for the bundled ARStack reference profile +- SCL/CID/SCD/IID import remains available when the compiled stream fits the same firmware boundary + +Profiles outside this layout may still be parsed and explained, but they are not P0 release targets and must remain blocked from deployment. + +## P0 work packages + +### P0.1 — Canonical Quick Start + +- [x] ARStack-owned 4I+4V reference SCL bundled with the native Qt application +- [x] template goes through the canonical C++ SCL parser/profile compiler +- [x] application-owned reference counter policy resolves the template to Class A +- [x] Home ribbon exposes one-click `4I+4V Quick Start` +- [x] CI regression checks Class A, device support, 4000 fps, 4000 modulus, 64-byte payload and 16 leaves +- [ ] first-run visual state makes `Quick Start -> Connect -> Deploy -> Start` self-explanatory + +### P0.2 — Release UI / UX polish + +- [ ] make ARStack Studio the only canonical public operator surface +- [ ] present device, firmware, profile and output state in one compact hierarchy +- [ ] keep Current / Voltage matrices as the primary work surface +- [ ] remove development-language friction from normal workflow +- [ ] ensure every disabled Deploy/Start state has an operator-readable reason +- [ ] preserve Expert diagnostics without making them primary UI +- [ ] verify keyboard, resizing, DPI scaling and laptop layouts + +### P0.3 — ESP32-P4-ETH Firmware Manager + +- [ ] identify supported ESP32-P4-ETH target before flash +- [ ] version GUI <-> firmware protocol/capabilities +- [ ] package a release firmware image and manifest with hashes +- [ ] install firmware from inside ARStack Studio without ESP-IDF +- [ ] support firmware update and recovery flow +- [ ] reset/reconnect and verify `IDENTIFY` after flash +- [ ] refuse incompatible target/image combinations + +### P0.4 — Windows public distribution + +- [ ] native Release build +- [ ] bundle required Qt runtime with deployment tooling +- [ ] portable package +- [ ] Windows installer +- [ ] clean-machine smoke test with no Qt/Python/CMake/Ninja/ESP-IDF installed +- [ ] versioned firmware bundled or release-resolved by manifest +- [ ] SHA-256 checksums and reproducible release metadata +- [ ] tagged GitHub Release workflow + +### P0.5 — Physical acceptance before public release + +- [ ] blank/recovery ESP32-P4-ETH -> Firmware Manager -> verified device +- [ ] bundled 4I+4V Quick Start -> Deploy -> Start +- [ ] external 4I+4V engineering SCL -> compile -> Deploy -> Start +- [ ] 4000 fps retained run with no unexpected missed slots / canonical TX failures +- [ ] live I/U/phase/frequency/quality edits without unintended `smpCnt` restart +- [ ] Stop / Zero / reconnect / power-cycle behavior +- [ ] trusted independent capture of destination MAC, APPID, VLAN/PCP, svID, confRev and counter continuity +- [ ] no synchronization claim beyond measured evidence; `smpSynch` truth boundary retained + +## Explicitly outside the first release + +The following are valuable follow-on work but must not delay or destabilize P0: + +- 3I / 8I / 8I+4V / 9I+6V / 12I+4V / 12I+8V runtime layouts +- multi-stream publication +- generalized arbitrary DataSet layouts +- COMTRADE playback +- scripted fault sequences +- full Process Bus analyzer +- formal conformance/certification claims + +This boundary can be widened only after the 4I+4V product path is packaged, recoverable, independently verified and usable by a new operator without development tooling. From b77d4bc9c88795279aa28b031934db0a536d5c2f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:19:19 +0700 Subject: [PATCH 011/272] fix(smv): accept 9-2LE AmpSv/VolSv 4I+4V semantics --- .../esp32p4_profile_support.hpp | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp index 6123f21ad..128d24054 100644 --- a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -40,6 +40,14 @@ namespace detail { return result; } +[[nodiscard]] inline bool sv_reference_matches_either( + const std::string_view reference, + const std::string_view canonical, + const std::string_view legacy_9_2le) { + return reference.find(canonical) != std::string_view::npos || + reference.find(legacy_9_2le) != std::string_view::npos; +} + [[nodiscard]] inline bool esp32p4_4i4v_layout_matches( const SvPublisherProfile& profile) { if (profile.no_asdu != 1U || profile.payload_size_bytes != 64U || @@ -56,6 +64,10 @@ namespace detail { // IA, IB, IC, IN, UA, UB, UC, UN, with one Quality word after each value. // Do not accept a merely shape-compatible DataSet: that would silently map // unrelated INT32 members onto the fixed injector channels. + // + // Both Amp/Vol and the 9-2LE-style AmpSv/VolSv DO naming are accepted only + // when the LN class/instance, order, width, basic type and Quality pairing + // all match the same proven 4I+4V runtime layout. constexpr std::array expected_values{ "tctr1.amp.instmag.i", "tctr2.amp.instmag.i", @@ -66,6 +78,16 @@ namespace detail { "tvtr3.vol.instmag.i", "tvtr4.vol.instmag.i", }; + constexpr std::array expected_values_9_2le{ + "tctr1.ampsv.instmag.i", + "tctr2.ampsv.instmag.i", + "tctr3.ampsv.instmag.i", + "tctr4.ampsv.instmag.i", + "tvtr1.volsv.instmag.i", + "tvtr2.volsv.instmag.i", + "tvtr3.volsv.instmag.i", + "tvtr4.volsv.instmag.i", + }; constexpr std::array expected_qualities{ "tctr1.amp.q", "tctr2.amp.q", @@ -76,6 +98,16 @@ namespace detail { "tvtr3.vol.q", "tvtr4.vol.q", }; + constexpr std::array expected_qualities_9_2le{ + "tctr1.ampsv.q", + "tctr2.ampsv.q", + "tctr3.ampsv.q", + "tctr4.ampsv.q", + "tvtr1.volsv.q", + "tvtr2.volsv.q", + "tvtr3.volsv.q", + "tvtr4.volsv.q", + }; for (std::size_t i = 0U; i < profile.channels.size(); ++i) { const auto& channel = profile.channels[i]; @@ -94,8 +126,14 @@ namespace detail { sv_support_lower_copy(profile.channels[signal * 2U].signal_reference); const auto quality_reference = sv_support_lower_copy(profile.channels[signal * 2U + 1U].signal_reference); - if (value_reference.find(expected_values[signal]) == std::string::npos || - quality_reference.find(expected_qualities[signal]) == std::string::npos) { + if (!sv_reference_matches_either( + value_reference, + expected_values[signal], + expected_values_9_2le[signal]) || + !sv_reference_matches_either( + quality_reference, + expected_qualities[signal], + expected_qualities_9_2le[signal])) { return false; } } From b1e72938d44124fb796cfcf1e2e1694ef240bd8b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:31:27 +0700 Subject: [PATCH 012/272] feat(studio): add fail-closed ESP32-P4 firmware manager --- apps/arstack_studio/src/FirmwareManager.hpp | 78 +++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/arstack_studio/src/FirmwareManager.hpp diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp new file mode 100644 index 000000000..97f51f593 --- /dev/null +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +class FirmwareManager : public QObject { + Q_OBJECT + Q_PROPERTY(bool bundleReady READ bundleReady NOTIFY stateChanged) + Q_PROPERTY(bool flasherAvailable READ flasherAvailable NOTIFY stateChanged) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(bool targetVerified READ targetVerified NOTIFY stateChanged) + Q_PROPERTY(QString selectedPort READ selectedPort NOTIFY stateChanged) + Q_PROPERTY(QString targetChip READ targetChip NOTIFY stateChanged) + Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged) + Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged) + Q_PROPERTY(QString firmwareSha256 READ firmwareSha256 NOTIFY stateChanged) + Q_PROPERTY(QString status READ status NOTIFY stateChanged) + Q_PROPERTY(QString bundleStatus READ bundleStatus NOTIFY stateChanged) + Q_PROPERTY(QString logText READ logText NOTIFY logChanged) + +public: + explicit FirmwareManager(QObject* parent = nullptr); + + [[nodiscard]] bool bundleReady() const noexcept { return bundleReady_; } + [[nodiscard]] bool flasherAvailable() const noexcept { return flasherAvailable_; } + [[nodiscard]] bool busy() const noexcept { return busy_; } + [[nodiscard]] bool targetVerified() const noexcept { return targetVerified_; } + [[nodiscard]] QString selectedPort() const { return selectedPort_; } + [[nodiscard]] QString targetChip() const { return targetChip_; } + [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; } + [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; } + [[nodiscard]] QString firmwareSha256() const { return firmwareSha256_; } + [[nodiscard]] QString status() const { return status_; } + [[nodiscard]] QString bundleStatus() const { return bundleStatus_; } + [[nodiscard]] QString logText() const { return logText_; } + + Q_INVOKABLE void refreshBundle(); + Q_INVOKABLE bool probeTarget(const QString& portName); + Q_INVOKABLE bool installFirmware(const QString& portName); + Q_INVOKABLE void cancel(); + Q_INVOKABLE void clearLog(); + +signals: + void stateChanged(); + void logChanged(); + void installationFinished(bool resetSucceeded); + +private: + enum class Operation { none, probe, flash, reset }; + + [[nodiscard]] QString bundleRoot() const; + [[nodiscard]] QString flasherPath() const; + bool loadManifest(); + bool startEspflash(const QStringList& arguments, Operation operation); + void finishOperation(int exitCode, QProcess::ExitStatus exitStatus); + void appendLog(const QString& text); + void setStatus(const QString& text); + void fail(const QString& text); + + QProcess process_; + Operation operation_{Operation::none}; + QString operationOutput_; + QString selectedPort_; + QString targetChip_{QStringLiteral("Not probed")}; + QString firmwareVersion_{QStringLiteral("-")}; + QString expectedProtocol_{QStringLiteral("-")}; + QString firmwareSha256_; + QString firmwareImagePath_; + QString status_{QStringLiteral("Firmware Manager ready")}; + QString bundleStatus_{QStringLiteral("Checking firmware package...")}; + QString logText_; + bool bundleReady_{false}; + bool flasherAvailable_{false}; + bool busy_{false}; + bool targetVerified_{false}; +}; From 847121c4656f61394aa194ddba5d35d88423273b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:32:04 +0700 Subject: [PATCH 013/272] feat(studio): implement ESP32-P4 firmware install and recovery flow --- apps/arstack_studio/src/FirmwareManager.cpp | 300 ++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 apps/arstack_studio/src/FirmwareManager.cpp diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp new file mode 100644 index 000000000..9dc2784cf --- /dev/null +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "FirmwareManager.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr auto kManifestName = "firmware-manifest.json"; +constexpr auto kManifestSchema = "arstack.studio.firmware.v1"; +constexpr auto kExpectedChip = "esp32p4"; + +QString normalizedHash(const QString& text) { + QString hash = text.trimmed().toLower(); + hash.remove(QLatin1Char(' ')); + return hash; +} +} // namespace + +FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { + process_.setProcessChannelMode(QProcess::SeparateChannels); + connect(&process_, &QProcess::readyReadStandardOutput, this, [this] { + const QString text = QString::fromUtf8(process_.readAllStandardOutput()); + operationOutput_ += text; + appendLog(text); + }); + connect(&process_, &QProcess::readyReadStandardError, this, [this] { + const QString text = QString::fromUtf8(process_.readAllStandardError()); + operationOutput_ += text; + appendLog(text); + }); + connect(&process_, qOverload(&QProcess::finished), + this, &FirmwareManager::finishOperation); + refreshBundle(); +} + +QString FirmwareManager::bundleRoot() const { + const QString overridePath = qEnvironmentVariable("ARSTACK_STUDIO_FIRMWARE_DIR").trimmed(); + if (!overridePath.isEmpty()) return QDir::cleanPath(overridePath); + return QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("firmware")); +} + +QString FirmwareManager::flasherPath() const { + const QString overridePath = qEnvironmentVariable("ARSTACK_STUDIO_ESPFLASH").trimmed(); + if (!overridePath.isEmpty()) return QDir::cleanPath(overridePath); +#ifdef Q_OS_WIN + return QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("tools/espflash.exe")); +#else + return QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("tools/espflash")); +#endif +} + +void FirmwareManager::refreshBundle() { + if (busy_) return; + bundleReady_ = false; + firmwareVersion_ = QStringLiteral("-"); + expectedProtocol_ = QStringLiteral("-"); + firmwareSha256_.clear(); + firmwareImagePath_.clear(); + + const QFileInfo flasherInfo{flasherPath()}; + flasherAvailable_ = flasherInfo.exists() && flasherInfo.isFile() && flasherInfo.isExecutable(); + if (!loadManifest()) { + emit stateChanged(); + return; + } + if (!flasherAvailable_) { + bundleStatus_ = QStringLiteral("Firmware image is valid, but bundled espflash is missing."); + emit stateChanged(); + return; + } + bundleReady_ = true; + bundleStatus_ = QStringLiteral("Ready · firmware v%1 · SHA-256 verified").arg(firmwareVersion_); + emit stateChanged(); +} + +bool FirmwareManager::loadManifest() { + const QDir root{bundleRoot()}; + QFile manifest{root.filePath(QString::fromLatin1(kManifestName))}; + if (!manifest.open(QIODevice::ReadOnly)) { + bundleStatus_ = QStringLiteral("No release firmware package is installed with this build."); + return false; + } + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(manifest.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + bundleStatus_ = QStringLiteral("Firmware manifest is invalid JSON."); + return false; + } + const QJsonObject object = document.object(); + if (object.value(QStringLiteral("schema")).toString() != QString::fromLatin1(kManifestSchema) || + object.value(QStringLiteral("chip")).toString().compare(QString::fromLatin1(kExpectedChip), Qt::CaseInsensitive) != 0) { + bundleStatus_ = QStringLiteral("Firmware manifest target/schema is incompatible with ARStack Studio."); + return false; + } + + const QString imageName = object.value(QStringLiteral("image")).toString().trimmed(); + const QString expectedHash = normalizedHash(object.value(QStringLiteral("sha256")).toString()); + const int protocol = object.value(QStringLiteral("protocol")).toInt(-1); + const QString version = object.value(QStringLiteral("version")).toString().trimmed(); + const qint64 flashOffset = object.value(QStringLiteral("flashOffset")).toVariant().toLongLong(); + + if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName || + expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0) { + bundleStatus_ = QStringLiteral("Firmware manifest fields are incomplete or unsafe."); + return false; + } + + const QString imagePath = root.filePath(imageName); + QFile image{imagePath}; + if (!image.open(QIODevice::ReadOnly)) { + bundleStatus_ = QStringLiteral("Firmware image referenced by the manifest is missing."); + return false; + } + + QCryptographicHash hasher{QCryptographicHash::Sha256}; + if (!hasher.addData(&image)) { + bundleStatus_ = QStringLiteral("Unable to hash the firmware image."); + return false; + } + const QString actualHash = QString::fromLatin1(hasher.result().toHex()).toLower(); + if (actualHash != expectedHash) { + bundleStatus_ = QStringLiteral("Firmware SHA-256 mismatch. Installation is blocked."); + return false; + } + + firmwareVersion_ = version; + expectedProtocol_ = QString::number(protocol); + firmwareSha256_ = actualHash; + firmwareImagePath_ = QDir::cleanPath(imagePath); + return true; +} + +bool FirmwareManager::probeTarget(const QString& portName) { + if (busy_) return false; + refreshBundle(); + if (!flasherAvailable_) { + fail(QStringLiteral("Bundled espflash executable is unavailable.")); + return false; + } + const QString port = portName.trimmed(); + if (port.isEmpty()) { + fail(QStringLiteral("Select the ESP32-P4 USB serial port first.")); + return false; + } + + selectedPort_ = port; + targetVerified_ = false; + targetChip_ = QStringLiteral("Probing..."); + busy_ = true; + setStatus(QStringLiteral("Reading target identity from %1...").arg(port)); + emit stateChanged(); + return startEspflash({QStringLiteral("--skip-update-check"), QStringLiteral("board-info")}, Operation::probe); +} + +bool FirmwareManager::installFirmware(const QString& portName) { + if (busy_) return false; + refreshBundle(); + const QString port = portName.trimmed(); + if (!bundleReady_) { + fail(bundleStatus_); + return false; + } + if (!targetVerified_ || port.isEmpty() || port != selectedPort_) { + fail(QStringLiteral("Probe and verify this ESP32-P4 port before flashing.")); + return false; + } + + busy_ = true; + setStatus(QStringLiteral("Installing ARStack firmware v%1...").arg(firmwareVersion_)); + emit stateChanged(); + return startEspflash({ + QStringLiteral("--skip-update-check"), + QStringLiteral("write-bin"), + QStringLiteral("--chip"), + QStringLiteral("esp32p4"), + QStringLiteral("0x0"), + firmwareImagePath_}, Operation::flash); +} + +void FirmwareManager::cancel() { + if (!busy_) return; + process_.kill(); + process_.waitForFinished(1200); + operation_ = Operation::none; + busy_ = false; + setStatus(QStringLiteral("Firmware operation cancelled.")); + emit stateChanged(); +} + +void FirmwareManager::clearLog() { + if (logText_.isEmpty()) return; + logText_.clear(); + emit logChanged(); +} + +bool FirmwareManager::startEspflash(const QStringList& arguments, const Operation operation) { + if (process_.state() != QProcess::NotRunning) { + fail(QStringLiteral("Another firmware process is already running.")); + return false; + } + + operation_ = operation; + operationOutput_.clear(); + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert(QStringLiteral("ESPFLASH_PORT"), selectedPort_); + environment.insert(QStringLiteral("ESPFLASH_SKIP_UPDATE_CHECK"), QStringLiteral("1")); + process_.setProcessEnvironment(environment); + process_.setProgram(flasherPath()); + process_.setArguments(arguments); + appendLog(QStringLiteral("$ espflash %1\n").arg(arguments.join(QLatin1Char(' ')))); + process_.start(); + if (!process_.waitForStarted(2500)) { + fail(QStringLiteral("Unable to start bundled espflash.")); + operation_ = Operation::none; + busy_ = false; + emit stateChanged(); + return false; + } + return true; +} + +void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitStatus exitStatus) { + const bool success = exitStatus == QProcess::NormalExit && exitCode == 0; + const Operation completed = operation_; + operation_ = Operation::none; + + if (completed == Operation::probe) { + busy_ = false; + const QString lower = operationOutput_.toLower(); + const bool p4 = success && (lower.contains(QStringLiteral("esp32-p4")) || + lower.contains(QStringLiteral("esp32p4"))); + targetVerified_ = p4; + targetChip_ = p4 ? QStringLiteral("ESP32-P4") : QStringLiteral("Unsupported / unknown target"); + setStatus(p4 + ? QStringLiteral("ESP32-P4 verified on %1. Firmware installation is unlocked.").arg(selectedPort_) + : QStringLiteral("Target verification failed. Only ESP32-P4 is accepted.")); + emit stateChanged(); + return; + } + + if (completed == Operation::flash) { + if (!success) { + busy_ = false; + fail(QStringLiteral("Firmware flash failed. Device contents were not trusted.")); + emit stateChanged(); + return; + } + setStatus(QStringLiteral("Flash completed. Resetting ESP32-P4...")); + emit stateChanged(); + if (!startEspflash({QStringLiteral("--skip-update-check"), QStringLiteral("reset")}, Operation::reset)) { + busy_ = false; + emit installationFinished(false); + } + return; + } + + if (completed == Operation::reset) { + busy_ = false; + if (success) { + targetVerified_ = false; + targetChip_ = QStringLiteral("Awaiting firmware IDENTIFY"); + setStatus(QStringLiteral("Firmware installed and reset. ARStack Studio will verify IDENTIFY next.")); + } else { + setStatus(QStringLiteral("Flash succeeded, but automatic reset failed. Replug the board and verify it.")); + } + emit stateChanged(); + emit installationFinished(success); + } +} + +void FirmwareManager::appendLog(const QString& text) { + if (text.isEmpty()) return; + const QString stamped = QStringLiteral("[%1] %2") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss")), text); + logText_ += stamped; + constexpr qsizetype kMax = 60000; + if (logText_.size() > kMax) logText_ = logText_.right(48000); + emit logChanged(); +} + +void FirmwareManager::setStatus(const QString& text) { + if (status_ == text) return; + status_ = text; + emit stateChanged(); +} + +void FirmwareManager::fail(const QString& text) { + setStatus(text); + appendLog(QStringLiteral("ERROR: %1\n").arg(text)); +} From 4f8dc86d44b630a6bd13d06f576a958d7f301819 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:32:29 +0700 Subject: [PATCH 014/272] feat(studio): add firmware install and recovery UI --- apps/arstack_studio/qml/FirmwarePanel.qml | 186 ++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 apps/arstack_studio/qml/FirmwarePanel.qml diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml new file mode 100644 index 000000000..885efaf5c --- /dev/null +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: panel + property var device + property var firmware + property string uiFont: "Inter" + property string monoFont: "Inter" + + Timer { + id: reconnectTimer + interval: 1600 + repeat: false + onTriggered: panel.device.autoDetectAndConnect() + } + + Connections { + target: panel.firmware + function onInstallationFinished(resetSucceeded) { + if (resetSucceeded) + reconnectTimer.restart() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "FIRMWARE MANAGER"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } + Label { text: "ESP32-P4-ETH install & recovery"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 15; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + Rectangle { + implicitWidth: 108 + implicitHeight: 24 + radius: 5 + color: panel.firmware.bundleReady ? "#112a20" : "#2a2112" + border.width: 1 + border.color: panel.firmware.bundleReady ? "#2c674e" : "#705827" + Label { + anchors.centerIn: parent + text: panel.firmware.bundleReady ? "BUNDLE VERIFIED" : "BUNDLE BLOCKED" + color: panel.firmware.bundleReady ? panel.theme.green : panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 7 + font.weight: Font.Bold + } + } + } + + Label { + Layout.fillWidth: true + text: "Safe flow: verify the ROM target first, validate the bundled firmware SHA-256, flash only an ESP32-P4, reset, then let Studio verify ARSTACK IDENTIFY. No ESP-IDF or Python is required on the operator PC." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + lineHeight: 1.35 + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 92 + radius: 7 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + GridLayout { + anchors.fill: parent + anchors.margins: 11 + columns: 4 + columnSpacing: 12 + rowSpacing: 6 + Label { text: "Package"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.firmware.firmwareVersion === "-" ? "Not installed" : "v" + panel.firmware.firmwareVersion; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { text: "Protocol"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.firmware.expectedProtocol === "-" ? "-" : "v" + panel.firmware.expectedProtocol; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: "Target"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.firmware.targetChip; color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { text: "SHA-256"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.firmware.firmwareSha256.length ? panel.firmware.firmwareSha256.slice(0, 12) + "…" : "-"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8 } + } + } + + Label { + Layout.fillWidth: true + text: panel.firmware.bundleStatus + color: panel.firmware.bundleReady ? panel.theme.green : panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + + RowLayout { + Layout.fillWidth: true + spacing: 7 + ComboBox { + id: firmwarePort + Layout.preferredWidth: 170 + model: panel.device.ports + enabled: !panel.firmware.busy + font.family: panel.uiFont + font.pixelSize: 9 + onPressedChanged: if (pressed) panel.device.refreshPorts() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh ports" + enabled: !panel.firmware.busy + onClicked: panel.device.refreshPorts() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: panel.firmware.busy ? "Working…" : "1 · Verify ESP32-P4" + tone: panel.firmware.targetVerified ? "success" : "accent" + enabled: !panel.firmware.busy && firmwarePort.currentText.length > 0 + onClicked: { + if (panel.device.connected) + panel.device.disconnectPort() + panel.firmware.probeTarget(firmwarePort.currentText) + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "2 · Install / Recover" + tone: "success" + enabled: panel.firmware.bundleReady && panel.firmware.targetVerified && !panel.firmware.busy && firmwarePort.currentText === panel.firmware.selectedPort + toolTipText: enabled ? "Flash the verified ARStack merged firmware image" : "Verify the ESP32-P4 target and firmware bundle first" + onClicked: panel.firmware.installFirmware(firmwarePort.currentText) + } + CalmButton { + visible: panel.firmware.busy + theme: panel.theme + uiFont: panel.uiFont + text: "Cancel" + tone: "danger" + onClicked: panel.firmware.cancel() + } + Item { Layout.fillWidth: true } + } + + Label { + Layout.fillWidth: true + text: panel.firmware.status + color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + Label { text: "FLASH LOG"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.8 } + Item { Layout.fillWidth: true } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Clear"; onClicked: panel.firmware.clearLog() } + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + TextArea { + readOnly: true + text: panel.firmware.logText + color: panel.theme.textSoft + selectionColor: panel.theme.accent + font.family: panel.monoFont + font.pixelSize: 8 + wrapMode: TextEdit.WrapAnywhere + background: Rectangle { color: "#090e14"; radius: 6; border.width: 1; border.color: panel.theme.lineSoft } + } + } + } +} From 16c302e20fe418a4f398eed107432e1f0fc3b3fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:32:43 +0700 Subject: [PATCH 015/272] feat(studio): wire firmware manager into native Qt build --- apps/arstack_studio/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 8a2f7e53c..34de6b992 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -23,6 +23,8 @@ qt_add_executable(arstack_studio src/SclProfileModel.hpp src/DeviceController.cpp src/DeviceController.hpp + src/FirmwareManager.cpp + src/FirmwareManager.hpp ) qt_add_qml_module(arstack_studio @@ -43,6 +45,7 @@ qt_add_qml_module(arstack_studio qml/ConfigurationHub.qml qml/PtpExpertPanel.qml qml/DeviceExpertPanel.qml + qml/FirmwarePanel.qml qml/WaveformExpertPanel.qml qml/SignalMatrix.qml qml/QualityEditor.qml @@ -88,6 +91,9 @@ target_link_libraries(arstack_studio PRIVATE ) target_compile_features(arstack_studio PRIVATE cxx_std_20) +target_compile_definitions(arstack_studio PRIVATE + ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" +) if(WIN32) set_target_properties(arstack_studio PROPERTIES WIN32_EXECUTABLE TRUE) From 61cdd0367fe3e6fbea10f9a3c9e8e67fa6c25d19 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:32:56 +0700 Subject: [PATCH 016/272] feat(studio): register firmware manager and centralize app version --- apps/arstack_studio/src/main.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 994cde9da..006c69f63 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "DeviceController.hpp" +#include "FirmwareManager.hpp" #include "SclProfileModel.hpp" #include @@ -12,6 +13,10 @@ #include #include +#ifndef ARSTACK_STUDIO_VERSION +#define ARSTACK_STUDIO_VERSION "0.1.0" +#endif + namespace { bool hasArgument(const int argc, char* argv[], const std::string_view wanted) { for (int i = 1; i < argc; ++i) { @@ -59,10 +64,11 @@ int main(int argc, char* argv[]) { QGuiApplication app(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); - QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0")); + QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); + qmlRegisterType("ARStack.Studio", 1, 0, "FirmwareManager"); QQmlApplicationEngine engine; QObject::connect( From 2305a0f0c0990005734b29d5e128add8bd0d02ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:33:13 +0700 Subject: [PATCH 017/272] feat(studio): add firmware manager to expert configuration --- apps/arstack_studio/qml/ConfigurationHub.qml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 6c57629f1..cbe28698f 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ARStack.Studio 1.0 Item { id: hub @@ -12,6 +13,8 @@ Item { property string uiFont: "Inter" property string monoFont: "Inter" + FirmwareManager { id: firmwareManager } + function openEngineeringFile() { profileInspector.openEngineeringFile() } component ExpertTab: TabButton { @@ -53,14 +56,14 @@ Item { Layout.rightMargin: 4 ColumnLayout { spacing: 0 - Label { text: "EXPERT CONFIGURATION"; color: hub.theme.muted; font.family: hub.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } - Label { text: "Protocol & timing setup"; color: hub.theme.text; font.family: hub.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } + Label { text: "CONFIGURATION & RECOVERY"; color: hub.theme.muted; font.family: hub.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } + Label { text: "Protocol, firmware & timing"; color: hub.theme.text; font.family: hub.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } Label { text: hub.device.deviceVerified - ? "ESP32-P4 · ID " + hub.device.deviceId.slice(-6) - : "Offline editing" + ? "ESP32-P4 · protocol v" + hub.device.protocolVersion + " · ID " + hub.device.deviceId.slice(-6) + : "Offline / recovery" color: hub.device.deviceVerified ? hub.theme.green : hub.theme.muted font.family: hub.uiFont font.pixelSize: 8 @@ -74,6 +77,7 @@ Item { implicitHeight: 36 background: Rectangle { color: hub.theme.surface2; radius: 6 } ExpertTab { text: "SV Profile" } + ExpertTab { text: "Firmware" } ExpertTab { text: "Waveform" } ExpertTab { text: "PTP Lab" } ExpertTab { text: "Device Protocol" } @@ -94,6 +98,13 @@ Item { monoFont: hub.monoFont compact: false } + FirmwarePanel { + theme: hub.theme + device: hub.device + firmware: firmwareManager + uiFont: hub.uiFont + monoFont: hub.monoFont + } WaveformExpertPanel { theme: hub.theme controller: hub.controller From 2ed721f68af6fa9a02f5f7f68008a5c877e81de4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:33:58 +0700 Subject: [PATCH 018/272] feat(studio): make P0 workflow states operator-readable and fail-closed --- apps/arstack_studio/qml/WorkflowBar.qml | 74 ++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 16549eebf..49b7e602e 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -13,6 +13,33 @@ SurfacePanel { property string monoFont: "Inter" property bool compact: false property int activeTab: 0 + readonly property bool p0FirmwareCompatible: device.deviceVerified && device.protocolVersion === "1" + readonly property bool deployReady: controller.canDeploy && p0FirmwareCompatible + readonly property bool startReady: + p0FirmwareCompatible && profiles.hasProfiles && controller.selectedProfileDeployable && + device.profileArmed && !controller.profileDirty && !device.running && !device.profileDeploying + + function deployReason() { + if (device.running) return "Stop SMV output before deploying a profile." + if (device.profileDeploying) return "Profile deployment is already in progress." + if (!device.deviceVerified) return "Connect and verify an ARStack ESP32-P4 first." + if (!p0FirmwareCompatible) return "Firmware protocol mismatch. Open Configuration → Firmware to install the P0 firmware." + if (!profiles.hasProfiles) return "Load 4I+4V Quick Start or a compatible SCL first." + if (!controller.selectedProfileDeployable) return "The selected SCL stream is outside the P0 4I+4V device boundary." + return "Deploy the validated 4I+4V SV profile to the injector." + } + + function startReason() { + if (device.running) return "SMV output is already running." + if (!device.deviceVerified) return "Connect and verify an ARStack ESP32-P4 first." + if (!p0FirmwareCompatible) return "Firmware protocol mismatch. Install the P0 firmware first." + if (!profiles.hasProfiles) return "Load 4I+4V Quick Start or a compatible SCL first." + if (!controller.selectedProfileDeployable) return "Selected profile is not deployable on the P0 4I+4V runtime." + if (controller.profileDirty) return "Profile changed. Deploy it again before Start." + if (!device.profileArmed) return "Deploy the validated profile before Start." + if (device.profileDeploying) return "Wait for profile deployment to finish." + return "Start 4I+4V Sampled Values output." + } implicitHeight: 94 color: theme.surface2 @@ -66,6 +93,15 @@ SurfacePanel { Layout.alignment: Qt.AlignVCenter } + component RailLabel: Label { + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 7 + font.weight: Font.DemiBold + font.letterSpacing: 0.35 + verticalAlignment: Text.AlignVCenter + } + ColumnLayout { anchors.fill: parent spacing: 0 @@ -81,6 +117,30 @@ SurfacePanel { RibbonTab { text: "View"; checked: ribbon.activeTab === 1; onClicked: ribbon.activeTab = 1 } RibbonTab { text: "Engineering"; checked: ribbon.activeTab === 2; onClicked: ribbon.activeTab = 2 } Item { Layout.fillWidth: true } + + RowLayout { + visible: !ribbon.compact + spacing: 9 + RailLabel { + text: ribbon.device.deviceVerified ? "DEVICE · VERIFIED" : "DEVICE · OFFLINE" + color: ribbon.device.deviceVerified ? ribbon.theme.green : ribbon.theme.muted + } + Rectangle { width: 1; height: 11; color: ribbon.theme.lineSoft } + RailLabel { + text: ribbon.device.deviceVerified ? "FW · P" + ribbon.device.protocolVersion : "FW · —" + color: ribbon.p0FirmwareCompatible ? ribbon.theme.green : ribbon.theme.muted + } + Rectangle { width: 1; height: 11; color: ribbon.theme.lineSoft } + RailLabel { + text: ribbon.profiles.referenceTemplateActive ? "PROFILE · 4I+4V" : (ribbon.profiles.hasProfiles ? "PROFILE · SCL" : "PROFILE · —") + color: ribbon.profiles.hasProfiles ? ribbon.theme.textSoft : ribbon.theme.muted + } + Rectangle { width: 1; height: 11; color: ribbon.theme.lineSoft } + RailLabel { + text: ribbon.device.running ? "OUTPUT · RUNNING" : (ribbon.device.profileArmed && !ribbon.controller.profileDirty ? "OUTPUT · ARMED" : "OUTPUT · SAFE") + color: ribbon.device.running ? ribbon.theme.green : (ribbon.device.profileArmed ? ribbon.theme.accent : ribbon.theme.muted) + } + } } Rectangle { Layout.fillWidth: true; height: 1; color: ribbon.theme.lineSoft } @@ -149,9 +209,9 @@ SurfacePanel { onClicked: ribbon.controller.runReadinessCheck() } RibbonAction { - text: "Configure" + text: "Configuration" iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") - toolTipText: "Open Smart and Expert configuration" + toolTipText: "Open profile, firmware and expert configuration" onClicked: ribbon.controller.openConfiguration() } @@ -164,8 +224,8 @@ SurfacePanel { text: ribbon.device.profileDeploying ? "Deploying…" : "Deploy" iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") implicitWidth: 86 - enabled: ribbon.controller.canDeploy - toolTipText: "Deploy the validated SV profile" + enabled: ribbon.deployReady + toolTipText: ribbon.deployReason() onClicked: ribbon.controller.deploySelectedProfile() } RibbonAction { @@ -174,7 +234,7 @@ SurfacePanel { iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") implicitWidth: 82 enabled: ribbon.device.deviceVerified && ribbon.device.running - toolTipText: "Stop SMV output" + toolTipText: ribbon.device.running ? "Stop SMV output" : "Output is already stopped" onClicked: ribbon.device.stop() } RibbonAction { @@ -182,8 +242,8 @@ SurfacePanel { text: "Start" iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") implicitWidth: 92 - enabled: ribbon.controller.canStart - toolTipText: "Start validated SMV output" + enabled: ribbon.startReady + toolTipText: ribbon.startReason() onClicked: ribbon.device.start() } } From 8a2b8edc21674f96d75205bb229dcb30fcddeaa7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:35:58 +0700 Subject: [PATCH 019/272] ci(firmware): publish merged ESP32-P4 image with verified manifest --- .github/workflows/esp32p4-smv-injector.yml | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 836854433..2bc94a4a0 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -26,6 +26,13 @@ on: - 'src/sampled_values/**' - '.github/workflows/esp32p4-smv-injector.yml' +permissions: + contents: read + +env: + ARSTACK_FIRMWARE_VERSION: '0.1.0' + ARSTACK_PROTOCOL_VERSION: '1' + jobs: build-esp32p4: runs-on: ubuntu-latest @@ -37,7 +44,74 @@ jobs: working-directory: embedded/esp32p4_smv_injector steps: - uses: actions/checkout@v4 + - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + - name: Build firmware run: source /opt/esp/idf/export.sh && idf.py build + + - name: Create single-image recovery firmware + run: | + set -euo pipefail + source /opt/esp/idf/export.sh + image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" + idf.py merge-bin -o "build/${image}" -f raw + test -s "build/${image}" + + - name: Create release manifest and SHA-256 contract + run: | + set -euo pipefail + image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" + sha="$(sha256sum "build/${image}" | awk '{print $1}')" + python3 - "$image" "$sha" "$GITHUB_SHA" <<'PY' + import json + import os + import sys + image, sha256, source_commit = sys.argv[1:] + manifest = { + "schema": "arstack.studio.firmware.v1", + "product": "ARStack ESP32-P4 SMV Injector", + "chip": "esp32p4", + "version": os.environ["ARSTACK_FIRMWARE_VERSION"], + "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]), + "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS"], + "flashOffset": 0, + "image": image, + "sha256": sha256, + "espIdf": "5.5.4", + "sourceCommit": source_commit, + } + with open("build/firmware-manifest.json", "w", encoding="utf-8", newline="\n") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + PY + python3 - <<'PY' + import hashlib, json, pathlib + root = pathlib.Path('build') + manifest = json.loads((root / 'firmware-manifest.json').read_text(encoding='utf-8')) + payload = (root / manifest['image']).read_bytes() + actual = hashlib.sha256(payload).hexdigest() + assert manifest['schema'] == 'arstack.studio.firmware.v1' + assert manifest['chip'] == 'esp32p4' + assert manifest['flashOffset'] == 0 + assert manifest['protocol'] == 1 + assert actual == manifest['sha256'] + print(f"Firmware package: PASS · {manifest['image']} · SHA-256 {actual}") + PY + + - name: Stage Studio firmware package + run: | + set -euo pipefail + mkdir -p build/studio-firmware + cp "build/arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" build/studio-firmware/ + cp build/firmware-manifest.json build/studio-firmware/ + sha256sum build/studio-firmware/* > build/studio-firmware/SHA256SUMS.txt + + - name: Upload ESP32-P4 firmware package + uses: actions/upload-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: embedded/esp32p4_smv_injector/build/studio-firmware/ + if-no-files-found: error + retention-days: 14 From b3a1f1794f1be57a0d6da59108ef3ac3296ec603 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:36:38 +0700 Subject: [PATCH 020/272] ci(firmware): make firmware package workflow reusable --- .github/workflows/esp32p4-smv-injector.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 2bc94a4a0..156297721 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -1,6 +1,7 @@ name: ESP32-P4 SMV Injector on: + workflow_call: push: paths: - 'embedded/esp32p4_smv_injector/**' From 867e9527a9c770f638fa4db0dae501ff4ff525a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:36:56 +0700 Subject: [PATCH 021/272] build(studio): add per-user Windows installer packaging --- .../packaging/ARStackStudio.nsi | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 apps/arstack_studio/packaging/ARStackStudio.nsi diff --git a/apps/arstack_studio/packaging/ARStackStudio.nsi b/apps/arstack_studio/packaging/ARStackStudio.nsi new file mode 100644 index 000000000..5d114bdda --- /dev/null +++ b/apps/arstack_studio/packaging/ARStackStudio.nsi @@ -0,0 +1,68 @@ +Unicode true + +!include "MUI2.nsh" + +!ifndef SOURCE_DIR + !error "SOURCE_DIR must point to the staged ARStack Studio directory" +!endif +!ifndef OUTPUT_FILE + !define OUTPUT_FILE "ARStack-Studio-Setup.exe" +!endif +!ifndef APP_VERSION + !define APP_VERSION "0.1.0" +!endif + +Name "ARStack Studio" +OutFile "${OUTPUT_FILE}" +InstallDir "$LOCALAPPDATA\Programs\ARStack Studio" +InstallDirRegKey HKCU "Software\ARStack61850\ARStack Studio" "InstallDir" +RequestExecutionLevel user +SetCompressor /SOLID lzma +SetCompressorDictSize 32 + +VIProductVersion "${APP_VERSION}.0" +VIAddVersionKey "ProductName" "ARStack Studio" +VIAddVersionKey "CompanyName" "ARStack61850" +VIAddVersionKey "FileDescription" "IEC 61850 Sampled Values Injector / Generator" +VIAddVersionKey "FileVersion" "${APP_VERSION}" +VIAddVersionKey "ProductVersion" "${APP_VERSION}" + +!define MUI_ABORTWARNING +!define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico" +!define MUI_UNICON "${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico" +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +Section "ARStack Studio" SEC_MAIN + SetOutPath "$INSTDIR" + File /r "${SOURCE_DIR}\*" + + WriteRegStr HKCU "Software\ARStack61850\ARStack Studio" "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" + + 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 +SectionEnd + +Section "Uninstall" + Delete "$SMPROGRAMS\ARStack Studio\ARStack Studio.lnk" + Delete "$SMPROGRAMS\ARStack Studio\Uninstall ARStack Studio.lnk" + RMDir "$SMPROGRAMS\ARStack Studio" + RMDir /r "$INSTDIR" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" + DeleteRegKey HKCU "Software\ARStack61850\ARStack Studio" +SectionEnd From 623d44ef917dbba0eb426f691c8c41b7646b24b5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:37:57 +0700 Subject: [PATCH 022/272] ci(release): add Windows portable installer and tagged release pipeline --- .github/workflows/arstack-studio-release.yml | 215 +++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 .github/workflows/arstack-studio-release.yml diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml new file mode 100644 index 000000000..aee27667b --- /dev/null +++ b/.github/workflows/arstack-studio-release.yml @@ -0,0 +1,215 @@ +name: ARStack Studio Release + +on: + workflow_dispatch: + pull_request: + paths: + - 'apps/arstack_studio/**' + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/**' + - 'src/**' + - '.github/workflows/arstack-studio-release.yml' + - '.github/workflows/esp32p4-smv-injector.yml' + push: + tags: + - 'v*' + +permissions: + contents: read + +env: + ARSTACK_VERSION: '0.1.0' + QT_VERSION: '6.8.4' + ESPFLASH_VERSION: '4.5.0' + ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' + +jobs: + firmware: + uses: ./.github/workflows/esp32p4-smv-injector.yml + + windows-package: + name: Windows x64 package + needs: firmware + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate release version contract + shell: powershell + run: | + $cmake = Get-Content 'apps/arstack_studio/CMakeLists.txt' -Raw + if ($cmake -notmatch "project\(ARStackStudio VERSION $([regex]::Escape($env:ARSTACK_VERSION)) ") { + throw "CMake project version does not match ARSTACK_VERSION=$env:ARSTACK_VERSION" + } + if ($env:GITHUB_REF -like 'refs/tags/*') { + $expected = "v$env:ARSTACK_VERSION" + if ($env:GITHUB_REF_NAME -ne $expected) { + throw "P0 release only accepts tag $expected; got $env:GITHUB_REF_NAME" + } + } + + - name: Download verified ESP32-P4 firmware package + uses: actions/download-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: firmware-package + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure native Release build + shell: powershell + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build native Release + shell: powershell + run: cmake --build build-arstack-studio --config Release --target arstack_studio --parallel + + - name: Stage Qt application + shell: powershell + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio' + New-Item -ItemType Directory -Force $stage | Out-Null + $sourceExe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' + if (-not (Test-Path $sourceExe)) { throw "Release executable missing: $sourceExe" } + $stageExe = Join-Path $stage 'ARStackStudio.exe' + Copy-Item $sourceExe $stageExe + windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $stageExe + Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, COPYRIGHT.md -Destination $stage + New-Item -ItemType Directory -Force (Join-Path $stage 'firmware') | Out-Null + Copy-Item 'firmware-package\*' -Destination (Join-Path $stage 'firmware') -Recurse + + - name: Bundle pinned standalone espflash + shell: powershell + run: | + $url = "https://github.com/esp-rs/espflash/releases/download/v$env:ESPFLASH_VERSION/espflash-x86_64-pc-windows-msvc.zip" + $zip = Join-Path $env:RUNNER_TEMP 'espflash.zip' + Invoke-WebRequest -Uri $url -OutFile $zip + $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:ESPFLASH_WINDOWS_ZIP_SHA256) { + throw "espflash asset SHA-256 mismatch: $actual" + } + $extract = Join-Path $env:RUNNER_TEMP 'espflash' + Expand-Archive -Path $zip -DestinationPath $extract -Force + $exe = Get-ChildItem $extract -Filter espflash.exe -Recurse | Select-Object -First 1 + if (-not $exe) { throw 'espflash.exe not found in verified archive' } + $tools = Join-Path $PWD 'dist\ARStack-Studio\tools' + New-Item -ItemType Directory -Force $tools | Out-Null + Copy-Item $exe.FullName (Join-Path $tools 'espflash.exe') + + $licenses = Join-Path $PWD 'dist\ARStack-Studio\licenses\espflash' + New-Item -ItemType Directory -Force $licenses | Out-Null + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/esp-rs/espflash/v$env:ESPFLASH_VERSION/LICENSE-APACHE" -OutFile (Join-Path $licenses 'LICENSE-APACHE') + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/esp-rs/espflash/v$env:ESPFLASH_VERSION/LICENSE-MIT" -OutFile (Join-Path $licenses 'LICENSE-MIT') + + - name: Clean-package software acceptance + shell: powershell + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio' + & (Join-Path $stage 'ARStackStudio.exe') --check-reference-template + if ($LASTEXITCODE -ne 0) { throw 'Packaged reference-template regression failed' } + & (Join-Path $stage 'tools\espflash.exe') --version + if ($LASTEXITCODE -ne 0) { throw 'Bundled espflash failed to execute' } + + $manifestPath = Join-Path $stage 'firmware\firmware-manifest.json' + $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json + if ($manifest.schema -ne 'arstack.studio.firmware.v1') { throw 'Bad firmware manifest schema' } + if ($manifest.chip -ne 'esp32p4') { throw 'Bad firmware target' } + if ([int]$manifest.protocol -ne 1) { throw 'Bad firmware protocol' } + if ([int64]$manifest.flashOffset -ne 0) { throw 'Bad firmware flash offset' } + $imagePath = Join-Path (Split-Path $manifestPath) $manifest.image + $actual = (Get-FileHash $imagePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $manifest.sha256.ToLowerInvariant()) { throw 'Packaged firmware SHA-256 mismatch' } + Write-Host "Clean package contract: PASS · Qt runtime + firmware + espflash present" + + - name: Build portable ZIP + shell: powershell + run: | + $zip = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip" + Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $zip -CompressionLevel Optimal -Force + + - name: Install NSIS + shell: powershell + run: choco install nsis -y --no-progress + + - name: Build Windows installer + shell: powershell + run: | + $stage = (Resolve-Path 'dist\ARStack-Studio').Path + $out = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe" + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path + & makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { throw 'NSIS installer build failed' } + + - name: Silent installer smoke test + shell: powershell + run: | + $installer = (Resolve-Path "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe").Path + $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioCleanInstall' + & $installer /S "/D=$installDir" + if ($LASTEXITCODE -ne 0) { throw 'Silent installer failed' } + $installedExe = Join-Path $installDir 'ARStackStudio.exe' + if (-not (Test-Path $installedExe)) { throw 'Installer did not deploy ARStackStudio.exe' } + & $installedExe --check-reference-template + if ($LASTEXITCODE -ne 0) { throw 'Installed application regression failed' } + if (-not (Test-Path (Join-Path $installDir 'firmware\firmware-manifest.json'))) { throw 'Installer omitted firmware bundle' } + if (-not (Test-Path (Join-Path $installDir 'tools\espflash.exe'))) { throw 'Installer omitted espflash' } + & (Join-Path $installDir 'Uninstall.exe') /S + + - name: Generate release checksums + shell: powershell + run: | + $files = @( + "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip", + "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe" + ) + $lines = foreach ($file in $files) { + $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $([IO.Path]::GetFileName($file))" + } + $lines | Set-Content 'dist\SHA256SUMS.txt' -Encoding ascii + Get-Content 'dist\SHA256SUMS.txt' + + - name: Upload Windows release candidates + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-windows-x64 + path: | + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-portable.zip + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-setup.exe + dist/SHA256SUMS.txt + if-no-files-found: error + retention-days: 14 + + github-release: + name: Publish tagged release + if: startsWith(github.ref, 'refs/tags/') + needs: windows-package + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download release candidates + uses: actions/download-artifact@v4 + with: + name: arstack-studio-windows-x64 + path: release + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ARStack Studio v${{ env.ARSTACK_VERSION }} + prerelease: true + generate_release_notes: true + files: | + release/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-portable.zip + release/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-setup.exe + release/SHA256SUMS.txt From c9380261a0318086376e7c0e8e512aecc30b9bb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:39:28 +0700 Subject: [PATCH 023/272] fix(studio): enforce P0 start guard in native controller boundary --- .../src/StudioDeviceController.hpp | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 apps/arstack_studio/src/StudioDeviceController.hpp diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp new file mode 100644 index 000000000..16a3fb375 --- /dev/null +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "DeviceController.hpp" + +class StudioDeviceController final : public DeviceController { + Q_OBJECT + +public: + explicit StudioDeviceController(QObject* parent = nullptr) : DeviceController(parent) {} + + Q_INVOKABLE bool start() { + if (!deviceVerified()) { + emit deviceMessage(QStringLiteral("Connect and verify the ARStack ESP32-P4 before Start.")); + return false; + } + if (protocolVersion() != QStringLiteral("1")) { + emit deviceMessage(QStringLiteral("Firmware protocol mismatch. Install the P0 firmware before Start.")); + return false; + } + if (!profileArmed() || profileDeploying()) { + emit deviceMessage(QStringLiteral("Deploy and arm a validated 4I+4V profile before Start.")); + return false; + } + return DeviceController::start(); + } + + Q_INVOKABLE bool deployProfile(const QVariantMap& profile) { + if (protocolVersion() != QStringLiteral("1")) { + emit deviceMessage(QStringLiteral("Firmware protocol mismatch. Profile deployment is blocked.")); + return false; + } + return DeviceController::deployProfile(profile); + } +}; From 7d28eb2df6cfab5dfa686ad37fdf59144da96ddf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:39:54 +0700 Subject: [PATCH 024/272] fix(studio): include guarded public device controller --- apps/arstack_studio/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 34de6b992..1d95df735 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -23,6 +23,7 @@ qt_add_executable(arstack_studio src/SclProfileModel.hpp src/DeviceController.cpp src/DeviceController.hpp + src/StudioDeviceController.hpp src/FirmwareManager.cpp src/FirmwareManager.hpp ) From cf271191e58ff554b17c65f0aebe0c92ff2d3b3a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:40:06 +0700 Subject: [PATCH 025/272] fix(studio): register guarded P0 device controller --- apps/arstack_studio/src/main.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 006c69f63..ab8b7f4fe 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -1,8 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#include "DeviceController.hpp" #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" +#include "StudioDeviceController.hpp" #include #include @@ -67,7 +67,7 @@ int main(int argc, char* argv[]) { QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); - qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); + qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); qmlRegisterType("ARStack.Studio", 1, 0, "FirmwareManager"); QQmlApplicationEngine engine; From f80850b1209dc9545035a4affcb56cb3182973fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:41:46 +0700 Subject: [PATCH 026/272] test(studio): add firmware bundle and start-policy self checks --- apps/arstack_studio/src/main.cpp | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index ab8b7f4fe..101fc6b6c 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -54,12 +54,48 @@ int checkReferenceTemplate(int argc, char* argv[]) { << "ARStack 4I+4V 9-2LE reference template: PASS · Class A · ready · 4000 fps · 64 B"; return 0; } + +int checkFirmwareContract(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + FirmwareManager firmware; + const bool valid = firmware.bundleReady() && firmware.flasherAvailable() && + firmware.firmwareVersion() == QStringLiteral(ARSTACK_STUDIO_VERSION) && + firmware.expectedProtocol() == QStringLiteral("1") && + firmware.firmwareSha256().size() == 64; + if (!valid) { + qCritical().noquote() << "Firmware bundle contract: FAIL ·" << firmware.bundleStatus(); + return 4; + } + qInfo().noquote() << "Firmware bundle contract: PASS ·" << firmware.bundleStatus(); + return 0; +} + +int checkP0ControllerPolicy(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + StudioDeviceController device; + if (device.start()) { + qCritical().noquote() << "P0 controller policy: FAIL · unverified device was allowed to START"; + return 5; + } + if (device.deployProfile({})) { + qCritical().noquote() << "P0 controller policy: FAIL · incompatible/unverified device accepted deploy"; + return 6; + } + qInfo().noquote() << "P0 controller policy: PASS · unverified START/DEPLOY fail closed"; + return 0; +} } // namespace int main(int argc, char* argv[]) { if (hasArgument(argc, argv, "--check-reference-template")) { return checkReferenceTemplate(argc, argv); } + if (hasArgument(argc, argv, "--check-firmware-contract")) { + return checkFirmwareContract(argc, argv); + } + if (hasArgument(argc, argv, "--check-p0-policy")) { + return checkP0ControllerPolicy(argc, argv); + } QGuiApplication app(argc, argv); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); From 53206577d8c4abd229f4a51f8c928dfe299f74c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:42:25 +0700 Subject: [PATCH 027/272] test(studio): gate firmware contract native policy and high-DPI launch --- .github/workflows/arstack-studio-qt.yml | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 654c88575..bf48723fb 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -82,6 +82,56 @@ jobs: ./build-arstack-studio/arstack_studio --check-reference-template \ 2>&1 | tee /tmp/arstack-studio-reference-template.log + - name: Native P0 controller policy regression + shell: bash + run: | + set -o pipefail + ./build-arstack-studio/arstack_studio --check-p0-policy \ + 2>&1 | tee /tmp/arstack-studio-p0-policy.log + + - name: Firmware bundle contract regression + shell: bash + run: | + set -euo pipefail + root=/tmp/arstack-firmware-contract + rm -rf "$root" + mkdir -p "$root/firmware" "$root/tools" + printf 'ARStack-P0-firmware-contract\n' > "$root/firmware/arstack-esp32p4-smv-0.1.0.bin" + sha="$(sha256sum "$root/firmware/arstack-esp32p4-smv-0.1.0.bin" | awk '{print $1}')" + cat > "$root/firmware/firmware-manifest.json" < "$root/tools/espflash" <<'EOF' + #!/bin/sh + echo 'espflash contract stub' + EOF + chmod +x "$root/tools/espflash" + + ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ + 2>&1 | tee /tmp/arstack-studio-firmware-contract.log + + zeros=0000000000000000000000000000000000000000000000000000000000000000 + sed -i "s/\"sha256\": \"$sha\"/\"sha256\": \"$zeros\"/" "$root/firmware/firmware-manifest.json" + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ + >> /tmp/arstack-studio-firmware-contract.log 2>&1; then + echo 'Corrupted firmware hash was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log + exit 1 + fi + echo 'Corrupted firmware hash: correctly blocked' | tee -a /tmp/arstack-studio-firmware-contract.log + - name: Headless QML launch smoke shell: bash run: | @@ -96,6 +146,20 @@ jobs: exit "$status" fi + - name: High-DPI QML launch smoke + shell: bash + run: | + set +e + timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_SCALE_FACTOR=1.75 \ + ./build-arstack-studio/arstack_studio > /tmp/arstack-studio-hidpi.log 2>&1 + status=$? + set -e + cat /tmp/arstack-studio-hidpi.log + if [ "$status" -ne 124 ]; then + echo "ARStack Studio high-DPI launch exited early (status=$status)." + exit "$status" + fi + - name: Retain Qt smoke logs if: always() uses: actions/upload-artifact@v4 @@ -105,6 +169,9 @@ jobs: /tmp/arstack-studio-configure.log /tmp/arstack-studio-build.log /tmp/arstack-studio-reference-template.log + /tmp/arstack-studio-p0-policy.log + /tmp/arstack-studio-firmware-contract.log /tmp/arstack-studio-launch.log + /tmp/arstack-studio-hidpi.log if-no-files-found: ignore retention-days: 7 From fe88723862df402569b80f727074d59698025069 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:45:15 +0700 Subject: [PATCH 028/272] docs(studio): focus operator docs on P0 4I+4V public release --- apps/arstack_studio/README.md | 265 ++++++++++++++-------------------- 1 file changed, 110 insertions(+), 155 deletions(-) diff --git a/apps/arstack_studio/README.md b/apps/arstack_studio/README.md index 6f8316ef5..be139bdcb 100644 --- a/apps/arstack_studio/README.md +++ b/apps/arstack_studio/README.md @@ -1,78 +1,116 @@ -# ARStack Studio — native Qt SMV Injector +# ARStack Studio — 4I + 4V SMV Injector / Generator -`apps/arstack_studio` is the native Qt 6 / C++ / QML operator surface for the ESP32-P4 Sampled Values injector. +ARStack Studio is the **canonical native desktop operator surface** for the first ARStack61850 public Sampled Values release. It is a Qt 6 / C++ / QML application; the ESP32-P4 remains the deterministic real-time publisher. -This branch is intentionally stacked on `feature/smv-scl-profile-bridge` so the desktop application follows the latest SCL/profile and embedded-runtime behavior instead of freezing an older web-GUI snapshot. +## P0 public boundary + +The first release is intentionally narrow and testable: + +- IEC 61850 Sampled Values, 9-2LE-style **4 current + 4 voltage**; +- wire order `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; +- one INT32 value plus one 32-bit Quality word for each signal; +- 16 FCDA leaves / 64-byte sample payload; +- **4000 frames/s**, `SmpPerSec`; +- one ASDU per frame; +- explicit `smpCnt` modulus 4000; +- ESP32-P4-ETH target running the ARStack protocol-v1 firmware. + +Broader codecs, SCL parsing, COMTRADE support, experimental timing work, or other layouts elsewhere in the repository do **not** expand this P0 deployment boundary. ## Architecture ```text -SCL / CID / SCD / IID +4I+4V Quick Start or compatible SCL/CID/SCD/IID -> ARIEC61850 SclParser -> SvPublisherProfileCompiler - -> shared ESP32-P4 deployment support classifier + -> fail-closed ESP32-P4 deployment classifier -> Qt SclProfileModel - -> operator selects / validates stream - -> Qt DeviceController (QSerialPort) - -> current ESP32-P4 PROFILE + live-control protocol - -> deterministic publisher on ESP32-P4 + -> operator Deploy + -> guarded Qt DeviceController + -> ARStack protocol v1 + -> deterministic ESP32-P4 SMV publisher ``` -The Qt application is a presentation and control plane. The PC is not the 4000/4800 fps realtime clock and does not replace the ESP32-P4 publisher. - -## Latest workflow carried into Qt - -- direct native SCL/CID/SCD/IID import using the repository C++ parser; -- resolved SampledValueControl stream selection; -- compatibility Class A / B / C; -- fail-closed ESP32-P4 device-support classification; -- explicit sample-counter-modulus confirmation for Class-B candidate profiles; -- explicit current counts/A and voltage counts/V test scaling; -- immutable profile deployment while STOPPED; -- profile identity: svID, data set, destination MAC, APPID, VLAN/PCP, confRev, publisher rate and counter modulus; -- optional DataSet / SampleRate ASDU-field flags already supported by the current device bridge; -- native serial 115200 8N1 control using Qt SerialPort; -- smart USB discovery with firmware-handshake verification and sequential - read-only fallback probing when Windows exposes only generic COM metadata; -- explicit `IDENTIFY` device identity backed by the ESP32-P4 factory MAC, so - the normal workflow never asks the operator to choose a COM port; -- live magnitude, phase, editable AC frequency, DC, enable and quality controls; -- mandatory live apply after verified connection; no mode switch can leave the - operator editing a stale local copy while output is running; -- an ARSVIN-compatible CT-saturation test shape with bounded DC offset, - harmonic, order, and clip controls in Expert mode; -- START / STOP / ZERO / SHOW and runtime telemetry; -- keyboard-first 4I + 4V matrix navigation; -- 3-phase linkage; -- persistent generated phasor and waveform preview; -- PTP Lab TX status, bounded runtime configuration, start/stop, and counters; -- serial diagnostics surface. - -The current deployment gate intentionally accepts only the embedded layout already supported by the ESP32-P4 runtime. Broader SCL structures may parse correctly while remaining `unsupported-layout`; the GUI does not guess them into the firmware profile. +The desktop application never becomes the 4000 fps timing source. It configures and supervises the embedded publisher. -## Truthfulness boundary +## Normal operator workflow + +1. Open **ARStack Studio**. +2. Choose **4I+4V Quick Start**, or **Open SCL** for a compatible external profile. +3. Connect the ESP32-P4-ETH board. Studio verifies the `ARSTACK identity` handshake and protocol. +4. **Deploy** the validated Class-A 4I+4V profile. +5. Adjust Current / Voltage setpoints as required. +6. **Start** Sampled Values output. +7. **Stop** before changing immutable profile identity fields or before leaving the test setup. + +The Home ribbon exposes explicit `DEVICE`, `FW`, `PROFILE`, and `OUTPUT` state. Disabled Deploy/Start controls explain the blocking condition instead of silently doing nothing. + +Critical START and DEPLOY rules are repeated in the native controller, so keyboard shortcuts or alternate QML paths cannot bypass the public P0 safety boundary. + +## Blank board / firmware recovery -The phasor and waveform are **generated setpoint previews**. They visualize the configured engineering state. They are not independent Ethernet on-wire evidence. +For a blank board, recovery board, or protocol mismatch, open **Configuration → Firmware**. -CT Saturation is a deterministic test-waveform approximation, not a calibrated -electromagnetic CT model. Its preview and parameters are labeled accordingly. +The Firmware Manager uses this fail-closed sequence: -A later Process Bus monitor can add an observed trace and generated-vs-observed comparison without changing this distinction. +1. select the USB serial port; +2. **Verify ESP32-P4** using the ROM flasher connection; +3. validate `firmware-manifest.json`; +4. verify the packaged firmware SHA-256; +5. unlock **Install / Recover** only for a verified ESP32-P4; +6. write the single merged recovery image at flash offset `0x0`; +7. reset the chip; +8. reconnect and require the normal ARStack `IDENTIFY` handshake. + +A missing flasher, missing image, malformed manifest, incompatible chip declaration, unsafe flash offset, protocol mismatch, or SHA-256 mismatch keeps installation blocked. + +The redistributable Windows package includes a pinned standalone `espflash`; the operator does **not** need Rust, Python, ESP-IDF, CMake, Qt, or a source checkout. + +## Firmware package contract + +A release package contains: + +```text +ARStackStudio.exe +firmware/ + firmware-manifest.json + arstack-esp32p4-smv-0.1.0.bin + SHA256SUMS.txt +tools/ + espflash.exe +licenses/ + espflash/ +``` -`smpSynch` remains an embedded timing/synchronization truth. The GUI must not claim synchronized output until the runtime has measured synchronization evidence. +Manifest schema `arstack.studio.firmware.v1` binds the image to chip `esp32p4`, firmware version `0.1.0`, protocol `1`, flash offset `0`, the exact image filename, image SHA-256, ESP-IDF build version, and source commit. -## Build +Protocol `1` is the P0 GUI/firmware capability contract for the supported `SMV-4I4V`, profile deployment, and live setpoint workflow. -Qt 6.5+ is required with: +## Windows release artifacts -- Qt Core / Gui / Qml / Quick / QuickControls2 -- Qt SerialPort -- a C++20 compiler -- CMake 3.24+ +The P0 release workflow creates separate downloadable files: -### Windows example +- `ARStack-Studio-0.1.0-win-x64-portable.zip` +- `ARStack-Studio-0.1.0-win-x64-setup.exe` +- `SHA256SUMS.txt` -From the repository root with a Qt-enabled developer shell: +Both packages contain the Qt runtime, verified ESP32-P4 firmware bundle, and pinned standalone flasher. The installer is per-user and does not require administrator elevation. + +A `v0.1.0` tag is the only accepted P0 publish tag. The GitHub release is intentionally marked prerelease until physical P0 acceptance is complete. + +## Truthfulness boundary + +Phasor and waveform views are generated setpoint previews, not independent Ethernet capture evidence. `smpSynch` remains an embedded synchronization truth and must not be promoted to a synchronization claim without measured evidence. + +CI can prove parser/compiler behavior, firmware manifest/hash enforcement, native START/DEPLOY guards, Qt instantiation, high-DPI launch, firmware build/package generation, Windows portable construction, installer construction, and packaged-app smoke execution. CI cannot prove physical Ethernet waveform or timing behavior. + +The final physical gate is maintained in [`../../docs/SMV_STUDIO_P0_RELEASE.md`](../../docs/SMV_STUDIO_P0_RELEASE.md). + +## Development build + +Qt 6.5+ is required with Core, Gui, Qml, Quick, QuickControls2 and SerialPort, together with a C++20 compiler and CMake 3.24+. + +From a Qt-enabled Windows developer shell: ```powershell cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja -DCMAKE_BUILD_TYPE=Release @@ -80,114 +118,31 @@ cmake --build build-arstack-studio --target arstack_studio --parallel .\build-arstack-studio\arstack_studio.exe ``` -On the Windows development workstation used for this phase, Qt is installed at -`D:\Qt\6.8.3\msvc2022_64`. The `msvc2022_64` directory is the Qt binary-kit -name; the launcher does **not** require the Visual Studio 2022 IDE. It discovers -the newest installed Visual Studio x64 C++ toolchain with `vswhere`, including -Visual Studio 2026 / MSVC v145. - -Build and launch with one command: +The repository also provides: ```powershell .\apps\arstack_studio\run.cmd ``` -Or call the PowerShell launcher directly: +or: ```powershell .\apps\arstack_studio\run-windows.ps1 ``` -Use `-NoLaunch` for a build-only verification. Use `-VisualStudioRoot` only when -you intentionally want to override automatic Visual Studio discovery. Use -`-QtRoot` when the Qt kit is installed somewhere other than the default path. -The launcher imports the selected Visual Studio developer environment, verifies -`cl.exe`, and reuses Visual Studio's bundled Ninja without modifying the user's -global `PATH`. - -## P2 fast-workflow ribbon - -The compact Home / View / Engineering ribbon carries ARSVIN's short operator -loop into the native Qt surface without copying its visual styling. Home keeps -Balanced, Zero, Check, Configure, Deploy, Start, and Stop one action away. View -owns dock visibility and detachment; Engineering owns SCL, smart discovery, -PTP refresh, and diagnostics. Device and output state stay in the header/footer -instead of being repeated as large workflow cards. - -Keyboard operators can use `Ctrl+O`, `Ctrl+B`, `Ctrl+0`, `Ctrl+K`, `Ctrl+D`, -`F5`, and `F6`. Check reports readiness through the existing non-modal status -surface; it does not bypass the deployment gate. - -## P2 dock shell - -The main window now behaves as a compact engineering shell rather than a fixed -form. Engineering source, profile, scaling, quick setup, and runtime details -live in a dedicated Smart / Expert Configuration window instead of consuming -permanent space beside the injector. Phasor View and Waveform View are separate -right-side docks; each can be hidden or detached into its own real Qt window. -Status History and Output Monitor share a bottom dock that starts collapsed. -The View ribbon restores closed panels, detaches either plot, and expands the -bottom monitor. - -The shell uses nested draggable splitters: editor vs preview docks, Phasor vs -Waveform, and workspace vs bottom monitor. Every pane keeps a protective -minimum size, while its split can be resized directly like an engineering MDI -shell. The complete monitor header is clickable for smooth expand/collapse. - -Smart mode is the default and guides device recognition, PTP inspection, and SV -profile selection. Expert mode exposes the detailed SV profile compiler plus -bounded PTP domain, transportSpecific, VLAN, interval, and peer-delay controls. -Deploy and Start remain disabled until the serial response proves the connected -port is the ARStack ESP32-P4 injector. - -PTP remains explicitly labeled as a laboratory timing companion. The firmware -serial surface now supports `PTP SHOW`, `PTP START`, `PTP STOP`, and bounded -`PTP CONFIG`; none of these claims external grandmaster lock or changes the -truthful `smpSynch` boundary. - -The safety-critical editor remains the stable center pane. Split sizes are -operator-adjustable, while arbitrary drag-reordering and layout persistence -remain follow-up work; detaching a view must never move or duplicate the output -controls. - -For redistribution, package the required dynamically linked Qt runtime libraries with the normal Qt deployment tooling and review the applicable Qt/LGPL distribution obligations for the chosen module set. - -## CI - -`.github/workflows/arstack-studio-qt.yml` performs: - -1. standalone Qt configure; -2. native C++ / QML build; -3. headless QML launch smoke test; -4. retained configure/build/launch logs on every run. - -A green desktop CI proves that the application builds and QML instantiates in the hosted environment. It does **not** replace physical ESP32-P4 / Ethernet validation. - -## Physical acceptance still required - -Before this desktop GUI is treated as a proven replacement for the current bench surface, retain hardware evidence for at least: - -1. native serial `IDENTIFY`, SHOW, and PROFILE SHOW using updated firmware; -2. development 4000 fps START/STOP and live SET/FREQ/ENABLE/QUALITY updates; -3. SCL Class-A profile deployment followed by PROFILE committed/armed confirmation; -4. 4800 fps profile deployment and observed ~4800 fps with the expected smpCnt cycle; -5. live magnitude/phase changes without unintended counter restart; -6. DC and CT-saturation SHAPE commands with observed on-wire sample values; -7. PTP Lab TX configuration/counters and explicit non-lock truth labeling; -8. generated-vs-on-wire identity comparison using a trusted capture path. - -## UI direction - -The desktop visual target is a calm premium engineering instrument: - -- Inter Variable is bundled under the SIL Open Font License and used for all - interface, numeric, status, and plot-label text; no monospace family is used; -- compact Lucide SVG icons are bundled with upstream license notices for the - ribbon and dock controls; -- Current and Voltage are the primary working matrices; -- SCL/profile information stays available without dominating the work surface; -- phasor and waveform remain persistent; -- Start/Stop remain fixed and obvious; -- values are optimized for keyboard operation; -- no browser/local-web-server dependency is required by the desktop application; -- visualization remains intentionally lightweight before any future custom `QQuickItem`/scene-graph optimization. +These developer launchers are not required by the redistributable release. + +## Automated Studio gates + +`.github/workflows/arstack-studio-qt.yml` checks: + +- native Qt configure/build with warnings as errors; +- bundled 4I+4V reference profile contract; +- native P0 controller fail-closed policy; +- valid firmware bundle acceptance; +- corrupted firmware SHA-256 rejection; +- normal offscreen QML launch; +- high-DPI QML launch; +- Windows launcher PowerShell parsing. + +`.github/workflows/arstack-studio-release.yml` additionally builds the merged ESP32-P4 firmware package and the Windows portable/installer release candidates. From 656696aac4e993262b6544dfe18dec5313c901b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:46:42 +0700 Subject: [PATCH 029/272] ci(release): isolate package acceptance on fresh Windows runner --- .github/workflows/arstack-studio-release.yml | 113 ++++++++++++++----- 1 file changed, 85 insertions(+), 28 deletions(-) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index aee27667b..46abec909 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -110,26 +110,21 @@ jobs: Invoke-WebRequest -Uri "https://raw.githubusercontent.com/esp-rs/espflash/v$env:ESPFLASH_VERSION/LICENSE-APACHE" -OutFile (Join-Path $licenses 'LICENSE-APACHE') Invoke-WebRequest -Uri "https://raw.githubusercontent.com/esp-rs/espflash/v$env:ESPFLASH_VERSION/LICENSE-MIT" -OutFile (Join-Path $licenses 'LICENSE-MIT') - - name: Clean-package software acceptance + - name: Package contract acceptance shell: powershell run: | $stage = Join-Path $PWD 'dist\ARStack-Studio' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' & (Join-Path $stage 'ARStackStudio.exe') --check-reference-template if ($LASTEXITCODE -ne 0) { throw 'Packaged reference-template regression failed' } + & (Join-Path $stage 'ARStackStudio.exe') --check-firmware-contract + if ($LASTEXITCODE -ne 0) { throw 'Packaged firmware contract failed' } + & (Join-Path $stage 'ARStackStudio.exe') --check-p0-policy + if ($LASTEXITCODE -ne 0) { throw 'Packaged native policy check failed' } & (Join-Path $stage 'tools\espflash.exe') --version if ($LASTEXITCODE -ne 0) { throw 'Bundled espflash failed to execute' } - $manifestPath = Join-Path $stage 'firmware\firmware-manifest.json' - $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json - if ($manifest.schema -ne 'arstack.studio.firmware.v1') { throw 'Bad firmware manifest schema' } - if ($manifest.chip -ne 'esp32p4') { throw 'Bad firmware target' } - if ([int]$manifest.protocol -ne 1) { throw 'Bad firmware protocol' } - if ([int64]$manifest.flashOffset -ne 0) { throw 'Bad firmware flash offset' } - $imagePath = Join-Path (Split-Path $manifestPath) $manifest.image - $actual = (Get-FileHash $imagePath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $manifest.sha256.ToLowerInvariant()) { throw 'Packaged firmware SHA-256 mismatch' } - Write-Host "Clean package contract: PASS · Qt runtime + firmware + espflash present" - - name: Build portable ZIP shell: powershell run: | @@ -149,21 +144,6 @@ jobs: & makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { throw 'NSIS installer build failed' } - - name: Silent installer smoke test - shell: powershell - run: | - $installer = (Resolve-Path "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe").Path - $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioCleanInstall' - & $installer /S "/D=$installDir" - if ($LASTEXITCODE -ne 0) { throw 'Silent installer failed' } - $installedExe = Join-Path $installDir 'ARStackStudio.exe' - if (-not (Test-Path $installedExe)) { throw 'Installer did not deploy ARStackStudio.exe' } - & $installedExe --check-reference-template - if ($LASTEXITCODE -ne 0) { throw 'Installed application regression failed' } - if (-not (Test-Path (Join-Path $installDir 'firmware\firmware-manifest.json'))) { throw 'Installer omitted firmware bundle' } - if (-not (Test-Path (Join-Path $installDir 'tools\espflash.exe'))) { throw 'Installer omitted espflash' } - & (Join-Path $installDir 'Uninstall.exe') /S - - name: Generate release checksums shell: powershell run: | @@ -189,10 +169,87 @@ jobs: if-no-files-found: error retention-days: 14 + clean-windows-smoke: + name: Fresh Windows package smoke + needs: windows-package + runs-on: windows-2022 + steps: + - name: Download release candidates only + uses: actions/download-artifact@v4 + with: + name: arstack-studio-windows-x64 + path: release-candidates + + - name: Verify release checksums + shell: powershell + run: | + $expected = @{} + Get-Content 'release-candidates\SHA256SUMS.txt' | ForEach-Object { + if ($_ -match '^([0-9a-f]{64})\s+(.+)$') { $expected[$Matches[2]] = $Matches[1] } + } + foreach ($name in @( + "ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip", + "ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe")) { + $path = Join-Path 'release-candidates' $name + if (-not (Test-Path $path)) { throw "Missing release candidate: $name" } + $actual = (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expected[$name] -ne $actual) { throw "Release checksum mismatch: $name" } + } + + - name: Portable package smoke with restricted PATH + shell: powershell + run: | + $root = Join-Path $env:RUNNER_TEMP 'ARStackPortable' + Expand-Archive -Path "release-candidates\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip" -DestinationPath $root -Force + $exe = Join-Path $root 'ARStackStudio.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $root 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $root 'tools\espflash.exe' + $savedPath = $env:PATH + $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" + try { + & $exe --check-reference-template + if ($LASTEXITCODE -ne 0) { throw 'Portable reference-template check failed' } + & $exe --check-firmware-contract + if ($LASTEXITCODE -ne 0) { throw 'Portable firmware contract failed' } + & $exe --check-p0-policy + if ($LASTEXITCODE -ne 0) { throw 'Portable P0 policy failed' } + & (Join-Path $root 'tools\espflash.exe') --version + if ($LASTEXITCODE -ne 0) { throw 'Portable espflash failed' } + } finally { + $env:PATH = $savedPath + } + + - name: Installer smoke with restricted PATH + shell: powershell + run: | + $installer = (Resolve-Path "release-candidates\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe").Path + $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioInstall' + $savedPath = $env:PATH + $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" + try { + & $installer /S "/D=$installDir" + if ($LASTEXITCODE -ne 0) { throw 'Silent installer failed' } + $exe = Join-Path $installDir 'ARStackStudio.exe' + if (-not (Test-Path $exe)) { throw 'Installer did not deploy ARStackStudio.exe' } + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' + & $exe --check-reference-template + if ($LASTEXITCODE -ne 0) { throw 'Installed reference-template check failed' } + & $exe --check-firmware-contract + if ($LASTEXITCODE -ne 0) { throw 'Installed firmware contract failed' } + & $exe --check-p0-policy + if ($LASTEXITCODE -ne 0) { throw 'Installed P0 policy failed' } + & (Join-Path $installDir 'tools\espflash.exe') --version + if ($LASTEXITCODE -ne 0) { throw 'Installed espflash failed' } + & (Join-Path $installDir 'Uninstall.exe') /S + } finally { + $env:PATH = $savedPath + } + github-release: name: Publish tagged release if: startsWith(github.ref, 'refs/tags/') - needs: windows-package + needs: clean-windows-smoke runs-on: ubuntu-latest permissions: contents: write From 231044f18c37a4f03992697a7c3a66edd9ce20b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:50:03 +0700 Subject: [PATCH 030/272] ci(release): package P0 with public Qt 6.10.3 --- .github/workflows/arstack-studio-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 46abec909..a125f719d 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -19,7 +19,7 @@ permissions: env: ARSTACK_VERSION: '0.1.0' - QT_VERSION: '6.8.4' + QT_VERSION: '6.10.3' ESPFLASH_VERSION: '4.5.0' ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' From 6610eaa189049239d7d315c7b29caec67261dc88 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:51:13 +0700 Subject: [PATCH 031/272] ci(studio): cancel superseded PR validation runs --- .github/workflows/arstack-studio-qt.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index bf48723fb..53245f933 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -19,6 +19,10 @@ on: permissions: contents: read +concurrency: + group: arstack-studio-qt-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: windows-launcher-parse: runs-on: windows-latest From 3fcc83acf7ca8608d151e3c729feb4e5a26be97b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 10:51:54 +0700 Subject: [PATCH 032/272] ci(release): cancel superseded P0 packaging runs --- .github/workflows/arstack-studio-release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index a125f719d..8e537a3e9 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -17,6 +17,10 @@ on: permissions: contents: read +concurrency: + group: arstack-studio-release-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: ARSTACK_VERSION: '0.1.0' QT_VERSION: '6.10.3' From 30a8e37ae2cbf6e0050028f4078e7e138ca41fd5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:09:16 +0700 Subject: [PATCH 033/272] fix(studio): allow Qt QML wrapper for P0 device controller --- apps/arstack_studio/src/StudioDeviceController.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 16a3fb375..9ad9e5e48 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -3,7 +3,7 @@ #include "DeviceController.hpp" -class StudioDeviceController final : public DeviceController { +class StudioDeviceController : public DeviceController { Q_OBJECT public: From 25a23d1f7a9a946a352d397a65513defeaf58692 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:14:11 +0700 Subject: [PATCH 034/272] fix(firmware): make P0 ESP32-P4 pre-v3 build reproducible --- embedded/esp32p4_smv_injector/sdkconfig.defaults | 3 +++ 1 file changed, 3 insertions(+) diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index e5908e179..3f9d52ca4 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -1,5 +1,8 @@ # ARStack ESP32-P4-ETH SMV Injector defaults. CONFIG_IDF_TARGET="esp32p4" +# The proven P0 hardware uses ESP32-P4 pre-v3 silicon (bench: rev v1.3). +# ESP-IDF 5.5 defaults to >=v3 silicon unless this selector is enabled. +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y CONFIG_COMPILER_CXX_EXCEPTIONS=y CONFIG_COMPILER_OPTIMIZATION_PERF=y CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 From 96e99093fa8545a78fb69fb56608db695245b15b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:14:37 +0700 Subject: [PATCH 035/272] fix(firmware): enforce ESP32-P4 pre-v3 release policy --- .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 156297721..8ec75b7f0 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -49,6 +49,12 @@ jobs: - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + - name: Verify P0 pre-v3 silicon policy + run: | + set -euo pipefail + grep -qx 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' sdkconfig + echo 'ESP32-P4 pre-v3 build policy: PASS' + - name: Build firmware run: source /opt/esp/idf/export.sh && idf.py build @@ -74,6 +80,7 @@ jobs: "schema": "arstack.studio.firmware.v1", "product": "ARStack ESP32-P4 SMV Injector", "chip": "esp32p4", + "chipRevisionPolicy": "pre-v3", "version": os.environ["ARSTACK_FIRMWARE_VERSION"], "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]), "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS"], @@ -95,10 +102,11 @@ jobs: actual = hashlib.sha256(payload).hexdigest() assert manifest['schema'] == 'arstack.studio.firmware.v1' assert manifest['chip'] == 'esp32p4' + assert manifest['chipRevisionPolicy'] == 'pre-v3' assert manifest['flashOffset'] == 0 assert manifest['protocol'] == 1 assert actual == manifest['sha256'] - print(f"Firmware package: PASS · {manifest['image']} · SHA-256 {actual}") + print(f"Firmware package: PASS · {manifest['image']} · pre-v3 · SHA-256 {actual}") PY - name: Stage Studio firmware package From 218e44239840710bdcf5627d9fbc7ef503430398 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:14:53 +0700 Subject: [PATCH 036/272] feat(firmware): track release silicon revision policy --- apps/arstack_studio/src/FirmwareManager.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 97f51f593..d940365a7 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -66,6 +66,7 @@ class FirmwareManager : public QObject { QString targetChip_{QStringLiteral("Not probed")}; QString firmwareVersion_{QStringLiteral("-")}; QString expectedProtocol_{QStringLiteral("-")}; + QString revisionPolicy_; QString firmwareSha256_; QString firmwareImagePath_; QString status_{QStringLiteral("Firmware Manager ready")}; From b7f8049b598983721721ff126f702e153cd15507 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:15:31 +0700 Subject: [PATCH 037/272] fix(firmware): verify ESP32-P4 silicon revision before flash --- apps/arstack_studio/src/FirmwareManager.cpp | 58 ++++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 9dc2784cf..927cad03a 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -17,6 +17,7 @@ namespace { constexpr auto kManifestName = "firmware-manifest.json"; constexpr auto kManifestSchema = "arstack.studio.firmware.v1"; constexpr auto kExpectedChip = "esp32p4"; +constexpr auto kPreV3Policy = "pre-v3"; QString normalizedHash(const QString& text) { QString hash = text.trimmed().toLower(); @@ -63,6 +64,7 @@ void FirmwareManager::refreshBundle() { bundleReady_ = false; firmwareVersion_ = QStringLiteral("-"); expectedProtocol_ = QStringLiteral("-"); + revisionPolicy_.clear(); firmwareSha256_.clear(); firmwareImagePath_.clear(); @@ -78,7 +80,8 @@ void FirmwareManager::refreshBundle() { return; } bundleReady_ = true; - bundleStatus_ = QStringLiteral("Ready · firmware v%1 · SHA-256 verified").arg(firmwareVersion_); + bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · SHA-256 verified") + .arg(firmwareVersion_); emit stateChanged(); } @@ -107,10 +110,12 @@ bool FirmwareManager::loadManifest() { const QString expectedHash = normalizedHash(object.value(QStringLiteral("sha256")).toString()); const int protocol = object.value(QStringLiteral("protocol")).toInt(-1); const QString version = object.value(QStringLiteral("version")).toString().trimmed(); + const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower(); const qint64 flashOffset = object.value(QStringLiteral("flashOffset")).toVariant().toLongLong(); if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName || - expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0) { + expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 || + revisionPolicy != QString::fromLatin1(kPreV3Policy)) { bundleStatus_ = QStringLiteral("Firmware manifest fields are incomplete or unsafe."); return false; } @@ -135,6 +140,7 @@ bool FirmwareManager::loadManifest() { firmwareVersion_ = version; expectedProtocol_ = QString::number(protocol); + revisionPolicy_ = revisionPolicy; firmwareSha256_ = actualHash; firmwareImagePath_ = QDir::cleanPath(imagePath); return true; @@ -157,9 +163,12 @@ bool FirmwareManager::probeTarget(const QString& portName) { targetVerified_ = false; targetChip_ = QStringLiteral("Probing..."); busy_ = true; - setStatus(QStringLiteral("Reading target identity from %1...").arg(port)); + setStatus(QStringLiteral("Reading target identity and silicon revision from %1...").arg(port)); emit stateChanged(); - return startEspflash({QStringLiteral("--skip-update-check"), QStringLiteral("board-info")}, Operation::probe); + return startEspflash({ + QStringLiteral("--skip-update-check"), + QStringLiteral("board-info"), + QStringLiteral("--non-interactive")}, Operation::probe); } bool FirmwareManager::installFirmware(const QString& portName) { @@ -171,7 +180,7 @@ bool FirmwareManager::installFirmware(const QString& portName) { return false; } if (!targetVerified_ || port.isEmpty() || port != selectedPort_) { - fail(QStringLiteral("Probe and verify this ESP32-P4 port before flashing.")); + fail(QStringLiteral("Probe and verify a supported ESP32-P4 pre-v3 target before flashing.")); return false; } @@ -183,6 +192,7 @@ bool FirmwareManager::installFirmware(const QString& portName) { QStringLiteral("write-bin"), QStringLiteral("--chip"), QStringLiteral("esp32p4"), + QStringLiteral("--non-interactive"), QStringLiteral("0x0"), firmwareImagePath_}, Operation::flash); } @@ -236,14 +246,31 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt if (completed == Operation::probe) { busy_ = false; - const QString lower = operationOutput_.toLower(); - const bool p4 = success && (lower.contains(QStringLiteral("esp32-p4")) || - lower.contains(QStringLiteral("esp32p4"))); - targetVerified_ = p4; - targetChip_ = p4 ? QStringLiteral("ESP32-P4") : QStringLiteral("Unsupported / unknown target"); - setStatus(p4 - ? QStringLiteral("ESP32-P4 verified on %1. Firmware installation is unlocked.").arg(selectedPort_) - : QStringLiteral("Target verification failed. Only ESP32-P4 is accepted.")); + const QRegularExpression identityExpression{ + QStringLiteral(R"(Chip\s+type:\s*ESP32-P4\s*\(revision\s+v(\d+)\.(\d+)\))"), + QRegularExpression::CaseInsensitiveOption}; + const auto identity = identityExpression.match(operationOutput_); + const bool p4WithRevision = success && identity.hasMatch(); + const int major = p4WithRevision ? identity.captured(1).toInt() : -1; + const int minor = p4WithRevision ? identity.captured(2).toInt() : -1; + const bool supportedRevision = p4WithRevision && revisionPolicy_ == QString::fromLatin1(kPreV3Policy) && major < 3; + + targetVerified_ = supportedRevision; + if (p4WithRevision) { + targetChip_ = QStringLiteral("ESP32-P4 · revision v%1.%2").arg(major).arg(minor); + } else { + targetChip_ = QStringLiteral("Unsupported / unknown target"); + } + + if (supportedRevision) { + setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 verified on %3. Firmware installation is unlocked.") + .arg(major).arg(minor).arg(selectedPort_)); + } else if (p4WithRevision) { + setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 is outside this P0 firmware policy (pre-v3). Flash is blocked.") + .arg(major).arg(minor)); + } else { + setStatus(QStringLiteral("Target/revision verification failed. Only a confirmed ESP32-P4 pre-v3 target is accepted.")); + } emit stateChanged(); return; } @@ -257,7 +284,10 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt } setStatus(QStringLiteral("Flash completed. Resetting ESP32-P4...")); emit stateChanged(); - if (!startEspflash({QStringLiteral("--skip-update-check"), QStringLiteral("reset")}, Operation::reset)) { + if (!startEspflash({ + QStringLiteral("--skip-update-check"), + QStringLiteral("reset"), + QStringLiteral("--non-interactive")}, Operation::reset)) { busy_ = false; emit installationFinished(false); } From ef29ff813ca6d668274a8fc317801038282619be Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:16:06 +0700 Subject: [PATCH 038/272] test(studio): include P0 silicon policy in firmware contract --- .github/workflows/arstack-studio-qt.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 53245f933..6f27a480f 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -107,6 +107,7 @@ jobs: "schema": "arstack.studio.firmware.v1", "product": "ARStack ESP32-P4 SMV Injector", "chip": "esp32p4", + "chipRevisionPolicy": "pre-v3", "version": "0.1.0", "protocol": 1, "flashOffset": 0, From b52a1866b45b9e4f5a021869dc3e6874a65efcc4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 11:21:55 +0700 Subject: [PATCH 039/272] fix(ci): write merged ESP32-P4 image from build cwd --- .github/workflows/esp32p4-smv-injector.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 8ec75b7f0..d73731207 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -63,7 +63,9 @@ jobs: set -euo pipefail source /opt/esp/idf/export.sh image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" - idf.py merge-bin -o "build/${image}" -f raw + # idf.py runs esptool's merge_bin from inside the build directory. + # Passing build/ therefore resolves to build/build/ and fails. + idf.py merge-bin -o "${image}" -f raw test -s "build/${image}" - name: Create release manifest and SHA-256 contract From 201c5d87497b3eb42d07454c1d058142642086eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 12:49:11 +0700 Subject: [PATCH 040/272] fix(ci): wait for Windows GUI process exit codes --- .github/workflows/arstack-studio-release.yml | 76 ++++++++++++-------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 8e537a3e9..371d5cbb3 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -117,17 +117,23 @@ jobs: - name: Package contract acceptance shell: powershell run: | + function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$Label failed with exit code $($process.ExitCode)" + } + } + $stage = Join-Path $PWD 'dist\ARStack-Studio' + $studio = Join-Path $stage 'ARStackStudio.exe' + $espflash = Join-Path $stage 'tools\espflash.exe' $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' - $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' - & (Join-Path $stage 'ARStackStudio.exe') --check-reference-template - if ($LASTEXITCODE -ne 0) { throw 'Packaged reference-template regression failed' } - & (Join-Path $stage 'ARStackStudio.exe') --check-firmware-contract - if ($LASTEXITCODE -ne 0) { throw 'Packaged firmware contract failed' } - & (Join-Path $stage 'ARStackStudio.exe') --check-p0-policy - if ($LASTEXITCODE -ne 0) { throw 'Packaged native policy check failed' } - & (Join-Path $stage 'tools\espflash.exe') --version - if ($LASTEXITCODE -ne 0) { throw 'Bundled espflash failed to execute' } + $env:ARSTACK_STUDIO_ESPFLASH = $espflash + + Assert-ExitZero $studio @('--check-reference-template') 'Packaged reference-template regression' + Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' + Assert-ExitZero $studio @('--check-p0-policy') 'Packaged native policy check' + Assert-ExitZero $espflash @('--version') 'Bundled espflash execution' - name: Build portable ZIP shell: powershell @@ -203,22 +209,26 @@ jobs: - name: Portable package smoke with restricted PATH shell: powershell run: | + function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$Label failed with exit code $($process.ExitCode)" + } + } + $root = Join-Path $env:RUNNER_TEMP 'ARStackPortable' Expand-Archive -Path "release-candidates\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip" -DestinationPath $root -Force $exe = Join-Path $root 'ARStackStudio.exe' + $espflash = Join-Path $root 'tools\espflash.exe' $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $root 'firmware' - $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $root 'tools\espflash.exe' + $env:ARSTACK_STUDIO_ESPFLASH = $espflash $savedPath = $env:PATH $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" try { - & $exe --check-reference-template - if ($LASTEXITCODE -ne 0) { throw 'Portable reference-template check failed' } - & $exe --check-firmware-contract - if ($LASTEXITCODE -ne 0) { throw 'Portable firmware contract failed' } - & $exe --check-p0-policy - if ($LASTEXITCODE -ne 0) { throw 'Portable P0 policy failed' } - & (Join-Path $root 'tools\espflash.exe') --version - if ($LASTEXITCODE -ne 0) { throw 'Portable espflash failed' } + Assert-ExitZero $exe @('--check-reference-template') 'Portable reference-template check' + Assert-ExitZero $exe @('--check-firmware-contract') 'Portable firmware contract' + Assert-ExitZero $exe @('--check-p0-policy') 'Portable P0 policy' + Assert-ExitZero $espflash @('--version') 'Portable espflash' } finally { $env:PATH = $savedPath } @@ -226,26 +236,32 @@ jobs: - name: Installer smoke with restricted PATH shell: powershell run: | + function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$Label failed with exit code $($process.ExitCode)" + } + } + $installer = (Resolve-Path "release-candidates\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe").Path $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioInstall' $savedPath = $env:PATH $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" try { - & $installer /S "/D=$installDir" - if ($LASTEXITCODE -ne 0) { throw 'Silent installer failed' } + Assert-ExitZero $installer @('/S', "/D=$installDir") 'Silent installer' $exe = Join-Path $installDir 'ARStackStudio.exe' if (-not (Test-Path $exe)) { throw 'Installer did not deploy ARStackStudio.exe' } + $espflash = Join-Path $installDir 'tools\espflash.exe' $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' - $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' - & $exe --check-reference-template - if ($LASTEXITCODE -ne 0) { throw 'Installed reference-template check failed' } - & $exe --check-firmware-contract - if ($LASTEXITCODE -ne 0) { throw 'Installed firmware contract failed' } - & $exe --check-p0-policy - if ($LASTEXITCODE -ne 0) { throw 'Installed P0 policy failed' } - & (Join-Path $installDir 'tools\espflash.exe') --version - if ($LASTEXITCODE -ne 0) { throw 'Installed espflash failed' } - & (Join-Path $installDir 'Uninstall.exe') /S + $env:ARSTACK_STUDIO_ESPFLASH = $espflash + Assert-ExitZero $exe @('--check-reference-template') 'Installed reference-template check' + Assert-ExitZero $exe @('--check-firmware-contract') 'Installed firmware contract' + Assert-ExitZero $exe @('--check-p0-policy') 'Installed P0 policy' + Assert-ExitZero $espflash @('--version') 'Installed espflash' + $uninstaller = Join-Path $installDir 'Uninstall.exe' + if (Test-Path $uninstaller) { + Assert-ExitZero $uninstaller @('/S') 'Silent uninstaller' + } } finally { $env:PATH = $savedPath } From f369464288db88047d6ce434f18864b778f9398c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:42:42 +0700 Subject: [PATCH 041/272] fix(studio): make espflash 4.5 recovery invocation compatible --- apps/arstack_studio/src/FirmwareManager.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 927cad03a..ca4ae71dd 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -166,7 +166,6 @@ bool FirmwareManager::probeTarget(const QString& portName) { setStatus(QStringLiteral("Reading target identity and silicon revision from %1...").arg(port)); emit stateChanged(); return startEspflash({ - QStringLiteral("--skip-update-check"), QStringLiteral("board-info"), QStringLiteral("--non-interactive")}, Operation::probe); } @@ -188,7 +187,6 @@ bool FirmwareManager::installFirmware(const QString& portName) { setStatus(QStringLiteral("Installing ARStack firmware v%1...").arg(firmwareVersion_)); emit stateChanged(); return startEspflash({ - QStringLiteral("--skip-update-check"), QStringLiteral("write-bin"), QStringLiteral("--chip"), QStringLiteral("esp32p4"), @@ -223,7 +221,9 @@ bool FirmwareManager::startEspflash(const QStringList& arguments, const Operatio operationOutput_.clear(); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert(QStringLiteral("ESPFLASH_PORT"), selectedPort_); - environment.insert(QStringLiteral("ESPFLASH_SKIP_UPDATE_CHECK"), QStringLiteral("1")); + // espflash 4.x parses this environment setting as a strict boolean. + // "1" is rejected; use the documented textual boolean instead. + environment.insert(QStringLiteral("ESPFLASH_SKIP_UPDATE_CHECK"), QStringLiteral("true")); process_.setProcessEnvironment(environment); process_.setProgram(flasherPath()); process_.setArguments(arguments); @@ -285,7 +285,6 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt setStatus(QStringLiteral("Flash completed. Resetting ESP32-P4...")); emit stateChanged(); if (!startEspflash({ - QStringLiteral("--skip-update-check"), QStringLiteral("reset"), QStringLiteral("--non-interactive")}, Operation::reset)) { busy_ = false; From 369a0fc0328646339cad106e2164b47d850b5084 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:44:51 +0700 Subject: [PATCH 042/272] feat(studio): redesign firmware recovery as guided operator flow --- apps/arstack_studio/qml/FirmwarePanel.qml | 424 +++++++++++++++++----- 1 file changed, 329 insertions(+), 95 deletions(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 885efaf5c..4cd06a9c0 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -9,6 +9,13 @@ SurfacePanel { property var firmware property string uiFont: "Inter" property string monoFont: "Inter" + property bool technicalDetailsVisible: false + + readonly property bool hasPort: firmwarePort.currentText.length > 0 + readonly property bool canInstall: + panel.firmware.bundleReady && panel.firmware.targetVerified && + !panel.firmware.busy && panel.hasPort && + firmwarePort.currentText === panel.firmware.selectedPort Timer { id: reconnectTimer @@ -25,121 +32,323 @@ SurfacePanel { } } + component StepBadge: Rectangle { + property string stepText: "1" + property bool complete: false + implicitWidth: 28 + implicitHeight: 28 + radius: 14 + color: complete ? "#173c2c" : panel.theme.accentSoft + border.width: 1 + border.color: complete ? "#347a59" : "#315f8d" + Label { + anchors.centerIn: parent + text: parent.complete ? "✓" : parent.stepText + color: parent.complete ? panel.theme.green : panel.theme.text + font.family: panel.uiFont + font.pixelSize: 11 + font.weight: Font.Bold + } + } + + component DarkComboBox: ComboBox { + id: combo + implicitHeight: 38 + font.family: panel.uiFont + font.pixelSize: 10 + leftPadding: 12 + rightPadding: 34 + contentItem: Text { + leftPadding: 2 + rightPadding: 2 + text: combo.displayText + color: combo.enabled ? panel.theme.text : panel.theme.muted2 + font: combo.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + indicator: Label { + x: combo.width - width - 12 + y: (combo.height - height) / 2 + text: "⌄" + color: combo.enabled ? panel.theme.textSoft : panel.theme.muted2 + font.family: panel.uiFont + font.pixelSize: 13 + } + background: Rectangle { + radius: 7 + color: combo.hovered ? panel.theme.raisedHover : panel.theme.surface2 + border.width: 1 + border.color: combo.activeFocus ? panel.theme.accent : panel.theme.line + } + } + ColumnLayout { anchors.fill: parent - anchors.margins: 16 - spacing: 10 + anchors.margins: 18 + spacing: 12 RowLayout { Layout.fillWidth: true + spacing: 12 + ColumnLayout { - spacing: 1 - Label { text: "FIRMWARE MANAGER"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } - Label { text: "ESP32-P4-ETH install & recovery"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 15; font.weight: Font.DemiBold } + Layout.fillWidth: true + spacing: 2 + Label { + text: "FIRMWARE RECOVERY" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + font.weight: Font.Bold + font.letterSpacing: 0.9 + } + Label { + text: "Install ARStack firmware" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 18 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: panel.device.deviceVerified + ? "The connected injector is recognized. Use this page to reinstall or recover firmware when required." + : "Use this when the board is new, blank, or still running older firmware. It does not need to identify as an ARStack injector first." + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } } - Item { Layout.fillWidth: true } + Rectangle { - implicitWidth: 108 - implicitHeight: 24 - radius: 5 + implicitWidth: 126 + implicitHeight: 30 + radius: 7 color: panel.firmware.bundleReady ? "#112a20" : "#2a2112" border.width: 1 border.color: panel.firmware.bundleReady ? "#2c674e" : "#705827" Label { anchors.centerIn: parent - text: panel.firmware.bundleReady ? "BUNDLE VERIFIED" : "BUNDLE BLOCKED" + text: panel.firmware.bundleReady ? "PACKAGE READY" : "PACKAGE BLOCKED" color: panel.firmware.bundleReady ? panel.theme.green : panel.theme.amber font.family: panel.uiFont - font.pixelSize: 7 + font.pixelSize: 8 font.weight: Font.Bold } } } - Label { + Rectangle { Layout.fillWidth: true - text: "Safe flow: verify the ROM target first, validate the bundled firmware SHA-256, flash only an ESP32-P4, reset, then let Studio verify ARSTACK IDENTIFY. No ESP-IDF or Python is required on the operator PC." - wrapMode: Text.WordWrap - color: panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 9 - lineHeight: 1.35 + implicitHeight: 62 + radius: 8 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 11 + Rectangle { + width: 8 + height: 8 + radius: 4 + color: panel.firmware.bundleReady ? panel.theme.green : panel.theme.amber + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + text: panel.firmware.bundleReady + ? "ARStack firmware v" + panel.firmware.firmwareVersion + " is bundled and verified" + : "Firmware package is not ready" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: panel.firmware.bundleStatus + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + } } Rectangle { Layout.fillWidth: true - implicitHeight: 92 - radius: 7 + implicitHeight: 122 + radius: 9 color: panel.theme.surface2 border.width: 1 - border.color: panel.theme.lineSoft - GridLayout { + border.color: panel.firmware.targetVerified ? "#2c674e" : panel.theme.lineSoft + + RowLayout { anchors.fill: parent - anchors.margins: 11 - columns: 4 - columnSpacing: 12 - rowSpacing: 6 - Label { text: "Package"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.firmware.firmwareVersion === "-" ? "Not installed" : "v" + panel.firmware.firmwareVersion; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold } - Label { text: "Protocol"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.firmware.expectedProtocol === "-" ? "-" : "v" + panel.firmware.expectedProtocol; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } - Label { text: "Target"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.firmware.targetChip; color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold } - Label { text: "SHA-256"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.firmware.firmwareSha256.length ? panel.firmware.firmwareSha256.slice(0, 12) + "…" : "-"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8 } + anchors.margins: 14 + spacing: 13 + + StepBadge { + stepText: "1" + complete: panel.firmware.targetVerified + Layout.alignment: Qt.AlignTop + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 7 + Label { + text: "Choose the ESP32-P4 board" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: panel.firmware.targetVerified + ? panel.firmware.targetChip + " verified on " + panel.firmware.selectedPort + : "Select the USB COM port, then check the chip directly through its ROM bootloader. Old ARStack firmware is not required for this check." + color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + DarkComboBox { + id: firmwarePort + Layout.preferredWidth: 190 + model: panel.device.ports + enabled: !panel.firmware.busy + onPressedChanged: if (pressed) panel.device.refreshPorts() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh" + enabled: !panel.firmware.busy + onClicked: panel.device.refreshPorts() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: panel.firmware.busy ? "Checking…" : (panel.firmware.targetVerified ? "Board verified" : "Check board") + tone: panel.firmware.targetVerified ? "success" : "accent" + enabled: !panel.firmware.busy && panel.hasPort + toolTipText: "Read ESP32-P4 chip identity and silicon revision from the selected USB port" + onClicked: { + if (panel.device.connected) + panel.device.disconnectPort() + panel.firmware.probeTarget(firmwarePort.currentText) + } + } + Item { Layout.fillWidth: true } + } + } } } - Label { + Rectangle { Layout.fillWidth: true - text: panel.firmware.bundleStatus - color: panel.firmware.bundleReady ? panel.theme.green : panel.theme.amber - font.family: panel.uiFont - font.pixelSize: 8 - wrapMode: Text.WordWrap - } + implicitHeight: 118 + radius: 9 + color: panel.canInstall ? "#10251d" : panel.theme.surface2 + border.width: 1 + border.color: panel.canInstall ? "#347a59" : panel.theme.lineSoft - Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 13 - RowLayout { + StepBadge { + stepText: "2" + complete: panel.device.deviceVerified && panel.device.protocolVersion === panel.firmware.expectedProtocol + Layout.alignment: Qt.AlignTop + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + Label { + text: "Install firmware" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: panel.canInstall + ? "Board safety check passed. Install the bundled ARStack firmware, then Studio will reset and reconnect automatically." + : "Check the board first. Installation stays locked until an ESP32-P4 target allowed by this release is verified." + color: panel.canInstall ? panel.theme.textSoft : panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + implicitWidth: 178 + implicitHeight: 42 + text: panel.firmware.busy ? "Working…" : "Install ARStack v" + panel.firmware.firmwareVersion + tone: "success" + enabled: panel.canInstall + toolTipText: panel.canInstall + ? "Flash the verified ARStack recovery image to " + firmwarePort.currentText + : "Complete Check board first" + onClicked: panel.firmware.installFirmware(firmwarePort.currentText) + } + } + } + + Rectangle { Layout.fillWidth: true - spacing: 7 - ComboBox { - id: firmwarePort - Layout.preferredWidth: 170 - model: panel.device.ports - enabled: !panel.firmware.busy + implicitHeight: panel.firmware.status.length > 0 ? 50 : 0 + visible: panel.firmware.status.length > 0 + radius: 7 + color: panel.firmware.targetVerified ? "#10251d" : "#171d25" + border.width: 1 + border.color: panel.firmware.targetVerified ? "#2c674e" : panel.theme.lineSoft + Label { + anchors.fill: parent + anchors.margins: 11 + text: panel.firmware.status + color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.textSoft font.family: panel.uiFont font.pixelSize: 9 - onPressedChanged: if (pressed) panel.device.refreshPorts() - } - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - text: "Refresh ports" - enabled: !panel.firmware.busy - onClicked: panel.device.refreshPorts() + wrapMode: Text.WordWrap + verticalAlignment: Text.AlignVCenter } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 CalmButton { theme: panel.theme uiFont: panel.uiFont - text: panel.firmware.busy ? "Working…" : "1 · Verify ESP32-P4" - tone: panel.firmware.targetVerified ? "success" : "accent" - enabled: !panel.firmware.busy && firmwarePort.currentText.length > 0 - onClicked: { - if (panel.device.connected) - panel.device.disconnectPort() - panel.firmware.probeTarget(firmwarePort.currentText) - } + text: panel.technicalDetailsVisible ? "Hide technical details" : "Technical details" + onClicked: panel.technicalDetailsVisible = !panel.technicalDetailsVisible } - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - text: "2 · Install / Recover" - tone: "success" - enabled: panel.firmware.bundleReady && panel.firmware.targetVerified && !panel.firmware.busy && firmwarePort.currentText === panel.firmware.selectedPort - toolTipText: enabled ? "Flash the verified ARStack merged firmware image" : "Verify the ESP32-P4 target and firmware bundle first" - onClicked: panel.firmware.installFirmware(firmwarePort.currentText) + Label { + Layout.fillWidth: true + text: "Protocol, SHA-256 and raw flash log" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 } CalmButton { visible: panel.firmware.busy @@ -149,38 +358,63 @@ SurfacePanel { tone: "danger" onClicked: panel.firmware.cancel() } - Item { Layout.fillWidth: true } } - Label { + Rectangle { + visible: panel.technicalDetailsVisible Layout.fillWidth: true - text: panel.firmware.status - color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 9 - wrapMode: Text.WordWrap + implicitHeight: 64 + radius: 7 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + GridLayout { + anchors.fill: parent + anchors.margins: 10 + columns: 4 + columnSpacing: 12 + rowSpacing: 4 + Label { text: "Protocol"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.firmware.expectedProtocol === "-" ? "-" : "v" + panel.firmware.expectedProtocol; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: "Target"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.firmware.targetChip; color: panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: "SHA-256"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { Layout.columnSpan: 3; text: panel.firmware.firmwareSha256.length ? panel.firmware.firmwareSha256 : "-"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideMiddle } + } } - RowLayout { + ColumnLayout { + visible: panel.technicalDetailsVisible Layout.fillWidth: true - Label { text: "FLASH LOG"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.8 } - Item { Layout.fillWidth: true } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Clear"; onClicked: panel.firmware.clearLog() } + Layout.fillHeight: true + spacing: 6 + RowLayout { + Layout.fillWidth: true + Label { text: "FLASH LOG"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.7 } + Item { Layout.fillWidth: true } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Clear"; onClicked: panel.firmware.clearLog() } + } + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 120 + TextArea { + readOnly: true + text: panel.firmware.logText + color: panel.theme.textSoft + selectionColor: panel.theme.accent + font.family: panel.monoFont + font.pixelSize: 8 + wrapMode: TextEdit.WrapAnywhere + background: Rectangle { color: "#090e14"; radius: 6; border.width: 1; border.color: panel.theme.lineSoft } + } + } } - ScrollView { + Item { + visible: !panel.technicalDetailsVisible Layout.fillWidth: true Layout.fillHeight: true - TextArea { - readOnly: true - text: panel.firmware.logText - color: panel.theme.textSoft - selectionColor: panel.theme.accent - font.family: panel.monoFont - font.pixelSize: 8 - wrapMode: TextEdit.WrapAnywhere - background: Rectangle { color: "#090e14"; radius: 6; border.width: 1; border.color: panel.theme.lineSoft } - } } } } From ddaa166765f392774a349f6f7302c321d4004285 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:45:17 +0700 Subject: [PATCH 043/272] feat(studio): make firmware recovery the offline setup default --- apps/arstack_studio/qml/ConfigurationHub.qml | 58 ++++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index cbe28698f..300a1d06e 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -16,11 +16,19 @@ Item { FirmwareManager { id: firmwareManager } function openEngineeringFile() { profileInspector.openEngineeringFile() } + function showRecovery() { expertTabs.currentIndex = 1 } + + Component.onCompleted: { + // A blank/old-firmware board cannot identify as an ARStack injector yet. + // Make recovery the first screen operators see while offline. + if (!hub.device.deviceVerified) + expertTabs.currentIndex = 1 + } component ExpertTab: TabButton { - implicitHeight: 36 + implicitHeight: 38 font.family: hub.uiFont - font.pixelSize: 9 + font.pixelSize: 10 font.weight: checked ? Font.DemiBold : Font.Medium contentItem: Label { text: parent.text @@ -38,8 +46,8 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - anchors.leftMargin: 28 - anchors.rightMargin: 28 + anchors.leftMargin: 24 + anchors.rightMargin: 24 height: 2 color: hub.theme.accent } @@ -48,25 +56,39 @@ Item { ColumnLayout { anchors.fill: parent - spacing: 8 + spacing: 10 RowLayout { Layout.fillWidth: true - Layout.leftMargin: 4 - Layout.rightMargin: 4 + Layout.leftMargin: 6 + Layout.rightMargin: 6 + spacing: 12 ColumnLayout { - spacing: 0 - Label { text: "CONFIGURATION & RECOVERY"; color: hub.theme.muted; font.family: hub.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } - Label { text: "Protocol, firmware & timing"; color: hub.theme.text; font.family: hub.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } + Layout.fillWidth: true + spacing: 1 + Label { + text: "SETUP & RECOVERY" + color: hub.theme.muted + font.family: hub.uiFont + font.pixelSize: 8 + font.weight: Font.Bold + font.letterSpacing: 0.9 + } + Label { + text: "Device, firmware & engineering" + color: hub.theme.text + font.family: hub.uiFont + font.pixelSize: 16 + font.weight: Font.DemiBold + } } - Item { Layout.fillWidth: true } Label { text: hub.device.deviceVerified ? "ESP32-P4 · protocol v" + hub.device.protocolVersion + " · ID " + hub.device.deviceId.slice(-6) - : "Offline / recovery" - color: hub.device.deviceVerified ? hub.theme.green : hub.theme.muted + : "Recovery available" + color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber font.family: hub.uiFont - font.pixelSize: 8 + font.pixelSize: 9 font.weight: Font.DemiBold } } @@ -74,13 +96,13 @@ Item { TabBar { id: expertTabs Layout.fillWidth: true - implicitHeight: 36 - background: Rectangle { color: hub.theme.surface2; radius: 6 } - ExpertTab { text: "SV Profile" } + implicitHeight: 38 + background: Rectangle { color: hub.theme.surface2; radius: 7 } + ExpertTab { text: "SV Setup" } ExpertTab { text: "Firmware" } ExpertTab { text: "Waveform" } ExpertTab { text: "PTP Lab" } - ExpertTab { text: "Device Protocol" } + ExpertTab { text: "Device" } } StackLayout { From 7893ac0cfb4c420f824f77c0cc20a17bddbda659 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:46:01 +0700 Subject: [PATCH 044/272] feat(studio): surface firmware setup in the primary offline workflow --- apps/arstack_studio/qml/WorkflowBar.qml | 32 +++++++++++++++---------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 49b7e602e..3f245ed10 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -22,8 +22,8 @@ SurfacePanel { function deployReason() { if (device.running) return "Stop SMV output before deploying a profile." if (device.profileDeploying) return "Profile deployment is already in progress." - if (!device.deviceVerified) return "Connect and verify an ARStack ESP32-P4 first." - if (!p0FirmwareCompatible) return "Firmware protocol mismatch. Open Configuration → Firmware to install the P0 firmware." + if (!device.deviceVerified) return "Install or recover ARStack firmware, then verify the ESP32-P4." + if (!p0FirmwareCompatible) return "Firmware protocol mismatch. Open Firmware setup to install the P0 firmware." if (!profiles.hasProfiles) return "Load 4I+4V Quick Start or a compatible SCL first." if (!controller.selectedProfileDeployable) return "The selected SCL stream is outside the P0 4I+4V device boundary." return "Deploy the validated 4I+4V SV profile to the injector." @@ -31,7 +31,7 @@ SurfacePanel { function startReason() { if (device.running) return "SMV output is already running." - if (!device.deviceVerified) return "Connect and verify an ARStack ESP32-P4 first." + if (!device.deviceVerified) return "Install or recover ARStack firmware first." if (!p0FirmwareCompatible) return "Firmware protocol mismatch. Install the P0 firmware first." if (!profiles.hasProfiles) return "Load 4I+4V Quick Start or a compatible SCL first." if (!controller.selectedProfileDeployable) return "Selected profile is not deployable on the P0 4I+4V runtime." @@ -122,13 +122,13 @@ SurfacePanel { visible: !ribbon.compact spacing: 9 RailLabel { - text: ribbon.device.deviceVerified ? "DEVICE · VERIFIED" : "DEVICE · OFFLINE" - color: ribbon.device.deviceVerified ? ribbon.theme.green : ribbon.theme.muted + text: ribbon.device.deviceVerified ? "DEVICE · VERIFIED" : "DEVICE · SETUP" + color: ribbon.device.deviceVerified ? ribbon.theme.green : ribbon.theme.amber } Rectangle { width: 1; height: 11; color: ribbon.theme.lineSoft } RailLabel { - text: ribbon.device.deviceVerified ? "FW · P" + ribbon.device.protocolVersion : "FW · —" - color: ribbon.p0FirmwareCompatible ? ribbon.theme.green : ribbon.theme.muted + text: ribbon.device.deviceVerified ? "FW · P" + ribbon.device.protocolVersion : "FW · INSTALL" + color: ribbon.p0FirmwareCompatible ? ribbon.theme.green : ribbon.theme.amber } Rectangle { width: 1; height: 11; color: ribbon.theme.lineSoft } RailLabel { @@ -209,9 +209,14 @@ SurfacePanel { onClicked: ribbon.controller.runReadinessCheck() } RibbonAction { - text: "Configuration" - iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") - toolTipText: "Open profile, firmware and expert configuration" + text: ribbon.device.deviceVerified ? "Configuration" : "Install Firmware" + iconSource: ribbon.device.deviceVerified + ? Qt.resolvedUrl("../assets/lucide/settings-2.svg") + : Qt.resolvedUrl("../assets/lucide/download.svg") + tone: ribbon.device.deviceVerified ? "neutral" : "accent" + toolTipText: ribbon.device.deviceVerified + ? "Open profile, firmware and expert configuration" + : "Open guided firmware setup for a blank or old-firmware ESP32-P4" onClicked: ribbon.controller.openConfiguration() } @@ -312,8 +317,11 @@ SurfacePanel { spacing: 6 RibbonAction { - text: "Configuration" - iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + text: ribbon.device.deviceVerified ? "Configuration" : "Firmware Setup" + iconSource: ribbon.device.deviceVerified + ? Qt.resolvedUrl("../assets/lucide/settings-2.svg") + : Qt.resolvedUrl("../assets/lucide/download.svg") + tone: ribbon.device.deviceVerified ? "neutral" : "accent" onClicked: ribbon.controller.openConfiguration() } RibbonAction { From 40096583295957784f41b87ad5d624d5f2f41d35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:47:13 +0700 Subject: [PATCH 045/272] fix(studio): use existing firmware setup icon asset --- apps/arstack_studio/qml/WorkflowBar.qml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 3f245ed10..5686267ff 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -210,9 +210,7 @@ SurfacePanel { } RibbonAction { text: ribbon.device.deviceVerified ? "Configuration" : "Install Firmware" - iconSource: ribbon.device.deviceVerified - ? Qt.resolvedUrl("../assets/lucide/settings-2.svg") - : Qt.resolvedUrl("../assets/lucide/download.svg") + iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") tone: ribbon.device.deviceVerified ? "neutral" : "accent" toolTipText: ribbon.device.deviceVerified ? "Open profile, firmware and expert configuration" @@ -318,9 +316,7 @@ SurfacePanel { RibbonAction { text: ribbon.device.deviceVerified ? "Configuration" : "Firmware Setup" - iconSource: ribbon.device.deviceVerified - ? Qt.resolvedUrl("../assets/lucide/settings-2.svg") - : Qt.resolvedUrl("../assets/lucide/download.svg") + iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") tone: ribbon.device.deviceVerified ? "neutral" : "accent" onClicked: ribbon.controller.openConfiguration() } From 549245e149fa83e4afaf5465813d5d8cfeaf26de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 13:48:55 +0700 Subject: [PATCH 046/272] test(studio): validate espflash recovery CLI contract in release CI --- .github/workflows/arstack-studio-release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 371d5cbb3..1a07abe16 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -129,11 +129,15 @@ jobs: $espflash = Join-Path $stage 'tools\espflash.exe' $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = $espflash + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' Assert-ExitZero $studio @('--check-reference-template') 'Packaged reference-template regression' Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' Assert-ExitZero $studio @('--check-p0-policy') 'Packaged native policy check' Assert-ExitZero $espflash @('--version') 'Bundled espflash execution' + Assert-ExitZero $espflash @('board-info', '--non-interactive', '--help') 'espflash board-info recovery CLI contract' + Assert-ExitZero $espflash @('write-bin', '--chip', 'esp32p4', '--non-interactive', '--help') 'espflash write-bin recovery CLI contract' + Assert-ExitZero $espflash @('reset', '--non-interactive', '--help') 'espflash reset recovery CLI contract' - name: Build portable ZIP shell: powershell @@ -222,6 +226,7 @@ jobs: $espflash = Join-Path $root 'tools\espflash.exe' $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $root 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = $espflash + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' $savedPath = $env:PATH $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" try { @@ -254,6 +259,7 @@ jobs: $espflash = Join-Path $installDir 'tools\espflash.exe' $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = $espflash + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' Assert-ExitZero $exe @('--check-reference-template') 'Installed reference-template check' Assert-ExitZero $exe @('--check-firmware-contract') 'Installed firmware contract' Assert-ExitZero $exe @('--check-p0-policy') 'Installed P0 policy' From 8ec29f01c42058a30e6cd6efcd340dfcc4cbb422 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 14:18:53 +0700 Subject: [PATCH 047/272] fix(studio): expose robust P4 recovery probe state --- apps/arstack_studio/src/FirmwareManager.hpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index d940365a7..a0c5c70b4 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -11,6 +11,7 @@ class FirmwareManager : public QObject { Q_PROPERTY(bool flasherAvailable READ flasherAvailable NOTIFY stateChanged) Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) Q_PROPERTY(bool targetVerified READ targetVerified NOTIFY stateChanged) + Q_PROPERTY(bool bootloaderHelpNeeded READ bootloaderHelpNeeded NOTIFY stateChanged) Q_PROPERTY(QString selectedPort READ selectedPort NOTIFY stateChanged) Q_PROPERTY(QString targetChip READ targetChip NOTIFY stateChanged) Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged) @@ -27,6 +28,7 @@ class FirmwareManager : public QObject { [[nodiscard]] bool flasherAvailable() const noexcept { return flasherAvailable_; } [[nodiscard]] bool busy() const noexcept { return busy_; } [[nodiscard]] bool targetVerified() const noexcept { return targetVerified_; } + [[nodiscard]] bool bootloaderHelpNeeded() const noexcept { return bootloaderHelpNeeded_; } [[nodiscard]] QString selectedPort() const { return selectedPort_; } [[nodiscard]] QString targetChip() const { return targetChip_; } [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; } @@ -36,6 +38,11 @@ class FirmwareManager : public QObject { [[nodiscard]] QString bundleStatus() const { return bundleStatus_; } [[nodiscard]] QString logText() const { return logText_; } + // espflash 4.x prints its Chip Display value as "esp32p4", while some + // Espressif tooling prints "ESP32-P4". Keep one parser for both forms and + // regression-test it from the packaged application contract. + [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); + Q_INVOKABLE void refreshBundle(); Q_INVOKABLE bool probeTarget(const QString& portName); Q_INVOKABLE bool installFirmware(const QString& portName); @@ -63,17 +70,18 @@ class FirmwareManager : public QObject { Operation operation_{Operation::none}; QString operationOutput_; QString selectedPort_; - QString targetChip_{QStringLiteral("Not probed")}; + QString targetChip_{QStringLiteral("Not checked")}; QString firmwareVersion_{QStringLiteral("-")}; QString expectedProtocol_{QStringLiteral("-")}; QString revisionPolicy_; QString firmwareSha256_; QString firmwareImagePath_; - QString status_{QStringLiteral("Firmware Manager ready")}; + QString status_{QStringLiteral("Firmware setup ready")}; QString bundleStatus_{QStringLiteral("Checking firmware package...")}; QString logText_; bool bundleReady_{false}; bool flasherAvailable_{false}; bool busy_{false}; bool targetVerified_{false}; + bool bootloaderHelpNeeded_{false}; }; From 66b4b5469b8fb03e972807a4d92bdaff37ec7266 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 14:19:47 +0700 Subject: [PATCH 048/272] fix(studio): recognize real espflash P4 output and guide boot mode --- apps/arstack_studio/src/FirmwareManager.cpp | 73 +++++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index ca4ae71dd..4fcb4860e 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -43,6 +43,24 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { refreshBundle(); } +bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, int& minor) { + // espflash 4.x prints Chip::Display as "esp32p4". Espressif/esptool and + // older logs commonly use "ESP32-P4". Accept both spellings, but still + // require an explicit revision before the P0 flash gate can open. + static const QRegularExpression expression{ + QStringLiteral(R"(Chip\s+type:\s*ESP32[-_ ]?P4\s*\(revision\s+v(\d+)\.(\d+)\))"), + QRegularExpression::CaseInsensitiveOption}; + const auto match = expression.match(output); + if (!match.hasMatch()) { + major = -1; + minor = -1; + return false; + } + major = match.captured(1).toInt(); + minor = match.captured(2).toInt(); + return true; +} + QString FirmwareManager::bundleRoot() const { const QString overridePath = qEnvironmentVariable("ARSTACK_STUDIO_FIRMWARE_DIR").trimmed(); if (!overridePath.isEmpty()) return QDir::cleanPath(overridePath); @@ -161,9 +179,10 @@ bool FirmwareManager::probeTarget(const QString& portName) { selectedPort_ = port; targetVerified_ = false; - targetChip_ = QStringLiteral("Probing..."); + bootloaderHelpNeeded_ = false; + targetChip_ = QStringLiteral("Checking %1...").arg(port); busy_ = true; - setStatus(QStringLiteral("Reading target identity and silicon revision from %1...").arg(port)); + setStatus(QStringLiteral("Checking ESP32-P4 ROM identity on %1...").arg(port)); emit stateChanged(); return startEspflash({ QStringLiteral("board-info"), @@ -179,12 +198,13 @@ bool FirmwareManager::installFirmware(const QString& portName) { return false; } if (!targetVerified_ || port.isEmpty() || port != selectedPort_) { - fail(QStringLiteral("Probe and verify a supported ESP32-P4 pre-v3 target before flashing.")); + fail(QStringLiteral("Check and verify a supported ESP32-P4 pre-v3 board before flashing.")); return false; } + bootloaderHelpNeeded_ = false; busy_ = true; - setStatus(QStringLiteral("Installing ARStack firmware v%1...").arg(firmwareVersion_)); + setStatus(QStringLiteral("Installing ARStack firmware v%1 on %2...").arg(firmwareVersion_, port)); emit stateChanged(); return startEspflash({ QStringLiteral("write-bin"), @@ -227,7 +247,8 @@ bool FirmwareManager::startEspflash(const QStringList& arguments, const Operatio process_.setProcessEnvironment(environment); process_.setProgram(flasherPath()); process_.setArguments(arguments); - appendLog(QStringLiteral("$ espflash %1\n").arg(arguments.join(QLatin1Char(' ')))); + appendLog(QStringLiteral("$ ESPFLASH_PORT=%1 espflash %2\n") + .arg(selectedPort_, arguments.join(QLatin1Char(' ')))); process_.start(); if (!process_.waitForStarted(2500)) { fail(QStringLiteral("Unable to start bundled espflash.")); @@ -246,30 +267,36 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt if (completed == Operation::probe) { busy_ = false; - const QRegularExpression identityExpression{ - QStringLiteral(R"(Chip\s+type:\s*ESP32-P4\s*\(revision\s+v(\d+)\.(\d+)\))"), - QRegularExpression::CaseInsensitiveOption}; - const auto identity = identityExpression.match(operationOutput_); - const bool p4WithRevision = success && identity.hasMatch(); - const int major = p4WithRevision ? identity.captured(1).toInt() : -1; - const int minor = p4WithRevision ? identity.captured(2).toInt() : -1; - const bool supportedRevision = p4WithRevision && revisionPolicy_ == QString::fromLatin1(kPreV3Policy) && major < 3; + int major = -1; + int minor = -1; + const bool p4WithRevision = success && parseEsp32P4Revision(operationOutput_, major, minor); + const bool anyChipAnswered = success && operationOutput_.contains( + QRegularExpression{QStringLiteral(R"(Chip\s+type:)"), QRegularExpression::CaseInsensitiveOption}); + const bool supportedRevision = p4WithRevision && + revisionPolicy_ == QString::fromLatin1(kPreV3Policy) && major < 3; targetVerified_ = supportedRevision; + bootloaderHelpNeeded_ = !success; if (p4WithRevision) { targetChip_ = QStringLiteral("ESP32-P4 · revision v%1.%2").arg(major).arg(minor); + } else if (anyChipAnswered) { + targetChip_ = QStringLiteral("Different ESP chip detected"); } else { - targetChip_ = QStringLiteral("Unsupported / unknown target"); + targetChip_ = QStringLiteral("%1 · ROM not responding").arg(selectedPort_); } if (supportedRevision) { - setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 verified on %3. Firmware installation is unlocked.") + setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 verified on %3. Ready to install ARStack firmware.") .arg(major).arg(minor).arg(selectedPort_)); } else if (p4WithRevision) { - setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 is outside this P0 firmware policy (pre-v3). Flash is blocked.") + setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 is outside this firmware package (pre-v3). Installation is blocked.") .arg(major).arg(minor)); + } else if (anyChipAnswered) { + setStatus(QStringLiteral("A chip answered on %1, but it is not an ESP32-P4. Installation is blocked.") + .arg(selectedPort_)); } else { - setStatus(QStringLiteral("Target/revision verification failed. Only a confirmed ESP32-P4 pre-v3 target is accepted.")); + setStatus(QStringLiteral("%1 is visible in Windows, but the ESP32-P4 ROM did not answer. Put the board in Download mode, then click Check board again.") + .arg(selectedPort_)); } emit stateChanged(); return; @@ -278,10 +305,12 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt if (completed == Operation::flash) { if (!success) { busy_ = false; - fail(QStringLiteral("Firmware flash failed. Device contents were not trusted.")); + bootloaderHelpNeeded_ = true; + fail(QStringLiteral("Firmware flash failed. Keep the board in Download mode and retry. Device contents were not trusted.")); emit stateChanged(); return; } + bootloaderHelpNeeded_ = false; setStatus(QStringLiteral("Flash completed. Resetting ESP32-P4...")); emit stateChanged(); if (!startEspflash({ @@ -297,10 +326,12 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt busy_ = false; if (success) { targetVerified_ = false; - targetChip_ = QStringLiteral("Awaiting firmware IDENTIFY"); - setStatus(QStringLiteral("Firmware installed and reset. ARStack Studio will verify IDENTIFY next.")); + bootloaderHelpNeeded_ = false; + targetChip_ = QStringLiteral("Awaiting ARStack firmware"); + setStatus(QStringLiteral("Firmware installed. The board was reset; Studio is reconnecting and verifying ARStack identity.")); } else { - setStatus(QStringLiteral("Flash succeeded, but automatic reset failed. Replug the board and verify it.")); + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Flash succeeded, but automatic reset failed. Press RESET once or reconnect USB; Studio will then verify the board.")); } emit stateChanged(); emit installationFinished(success); From e7ad9c887600805d3b8bac6299a6baabdd6249cd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 14:20:43 +0700 Subject: [PATCH 049/272] ux(studio): guide ESP32-P4 download-mode recovery --- apps/arstack_studio/qml/FirmwarePanel.qml | 82 ++++++++++++++--------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 4cd06a9c0..f6c4cfd81 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -19,7 +19,7 @@ SurfacePanel { Timer { id: reconnectTimer - interval: 1600 + interval: 1800 repeat: false onTriggered: panel.device.autoDetectAndConnect() } @@ -35,9 +35,9 @@ SurfacePanel { component StepBadge: Rectangle { property string stepText: "1" property bool complete: false - implicitWidth: 28 - implicitHeight: 28 - radius: 14 + implicitWidth: 30 + implicitHeight: 30 + radius: 15 color: complete ? "#173c2c" : panel.theme.accentSoft border.width: 1 border.color: complete ? "#347a59" : "#315f8d" @@ -53,9 +53,9 @@ SurfacePanel { component DarkComboBox: ComboBox { id: combo - implicitHeight: 38 + implicitHeight: 40 font.family: panel.uiFont - font.pixelSize: 10 + font.pixelSize: 11 leftPadding: 12 rightPadding: 34 contentItem: Text { @@ -99,7 +99,7 @@ SurfacePanel { text: "FIRMWARE RECOVERY" color: panel.theme.muted font.family: panel.uiFont - font.pixelSize: 8 + font.pixelSize: 9 font.weight: Font.Bold font.letterSpacing: 0.9 } @@ -113,8 +113,8 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.device.deviceVerified - ? "The connected injector is recognized. Use this page to reinstall or recover firmware when required." - : "Use this when the board is new, blank, or still running older firmware. It does not need to identify as an ARStack injector first." + ? "The injector is recognized. Reinstall firmware here only when recovery or update is required." + : "For a new board, blank board, or older firmware. The board does not need to identify as ARStack before recovery." color: panel.theme.textSoft font.family: panel.uiFont font.pixelSize: 10 @@ -163,16 +163,18 @@ SurfacePanel { spacing: 1 Label { text: panel.firmware.bundleReady - ? "ARStack firmware v" + panel.firmware.firmwareVersion + " is bundled and verified" + ? "ARStack firmware v" + panel.firmware.firmwareVersion + " is already included" : "Firmware package is not ready" color: panel.theme.text font.family: panel.uiFont - font.pixelSize: 10 + font.pixelSize: 11 font.weight: Font.DemiBold } Label { Layout.fillWidth: true - text: panel.firmware.bundleStatus + text: panel.firmware.bundleReady + ? "No firmware download or .bin selection is required." + : panel.firmware.bundleStatus color: panel.theme.muted font.family: panel.uiFont font.pixelSize: 9 @@ -184,11 +186,11 @@ SurfacePanel { Rectangle { Layout.fillWidth: true - implicitHeight: 122 + implicitHeight: panel.firmware.bootloaderHelpNeeded ? 146 : 126 radius: 9 color: panel.theme.surface2 border.width: 1 - border.color: panel.firmware.targetVerified ? "#2c674e" : panel.theme.lineSoft + border.color: panel.firmware.targetVerified ? "#2c674e" : (panel.firmware.bootloaderHelpNeeded ? "#705827" : panel.theme.lineSoft) RowLayout { anchors.fill: parent @@ -205,7 +207,7 @@ SurfacePanel { Layout.fillWidth: true spacing: 7 Label { - text: "Choose the ESP32-P4 board" + text: "Connect and check the board" color: panel.theme.text font.family: panel.uiFont font.pixelSize: 12 @@ -215,18 +217,32 @@ SurfacePanel { Layout.fillWidth: true text: panel.firmware.targetVerified ? panel.firmware.targetChip + " verified on " + panel.firmware.selectedPort - : "Select the USB COM port, then check the chip directly through its ROM bootloader. Old ARStack firmware is not required for this check." + : (panel.hasPort + ? firmwarePort.currentText + " is visible in Windows. Check the chip through the ESP32-P4 ROM bootloader." + : "Connect the ESP32-P4 programming USB cable, then click Refresh.") color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.muted font.family: panel.uiFont - font.pixelSize: 9 + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + + Label { + visible: panel.firmware.bootloaderHelpNeeded + Layout.fillWidth: true + text: "ROM not responding: hold BOOT → press and release RESET → release BOOT → click Check board again." + color: panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold wrapMode: Text.WordWrap } + RowLayout { Layout.fillWidth: true spacing: 8 DarkComboBox { id: firmwarePort - Layout.preferredWidth: 190 + Layout.preferredWidth: 205 model: panel.device.ports enabled: !panel.firmware.busy onPressedChanged: if (pressed) panel.device.refreshPorts() @@ -241,10 +257,12 @@ SurfacePanel { CalmButton { theme: panel.theme uiFont: panel.uiFont - text: panel.firmware.busy ? "Checking…" : (panel.firmware.targetVerified ? "Board verified" : "Check board") + text: panel.firmware.busy + ? "Checking…" + : (panel.firmware.targetVerified ? "Board verified" : (panel.firmware.bootloaderHelpNeeded ? "Retry check" : "Check board")) tone: panel.firmware.targetVerified ? "success" : "accent" enabled: !panel.firmware.busy && panel.hasPort - toolTipText: "Read ESP32-P4 chip identity and silicon revision from the selected USB port" + toolTipText: "Read ESP32-P4 chip identity and silicon revision from the selected COM port" onClicked: { if (panel.device.connected) panel.device.disconnectPort() @@ -289,11 +307,11 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.canInstall - ? "Board safety check passed. Install the bundled ARStack firmware, then Studio will reset and reconnect automatically." - : "Check the board first. Installation stays locked until an ESP32-P4 target allowed by this release is verified." + ? "Board check passed. Install the bundled firmware; Studio will reset and reconnect automatically." + : "Installation unlocks only after the selected COM port is verified as an allowed ESP32-P4." color: panel.canInstall ? panel.theme.textSoft : panel.theme.muted font.family: panel.uiFont - font.pixelSize: 9 + font.pixelSize: 10 wrapMode: Text.WordWrap } } @@ -301,13 +319,13 @@ SurfacePanel { CalmButton { theme: panel.theme uiFont: panel.uiFont - implicitWidth: 178 + implicitWidth: 190 implicitHeight: 42 text: panel.firmware.busy ? "Working…" : "Install ARStack v" + panel.firmware.firmwareVersion tone: "success" enabled: panel.canInstall toolTipText: panel.canInstall - ? "Flash the verified ARStack recovery image to " + firmwarePort.currentText + ? "Flash the verified bundled ARStack firmware to " + firmwarePort.currentText : "Complete Check board first" onClicked: panel.firmware.installFirmware(firmwarePort.currentText) } @@ -316,19 +334,19 @@ SurfacePanel { Rectangle { Layout.fillWidth: true - implicitHeight: panel.firmware.status.length > 0 ? 50 : 0 + implicitHeight: panel.firmware.status.length > 0 ? 52 : 0 visible: panel.firmware.status.length > 0 radius: 7 - color: panel.firmware.targetVerified ? "#10251d" : "#171d25" + color: panel.firmware.targetVerified ? "#10251d" : (panel.firmware.bootloaderHelpNeeded ? "#261f13" : "#171d25") border.width: 1 - border.color: panel.firmware.targetVerified ? "#2c674e" : panel.theme.lineSoft + border.color: panel.firmware.targetVerified ? "#2c674e" : (panel.firmware.bootloaderHelpNeeded ? "#705827" : panel.theme.lineSoft) Label { anchors.fill: parent anchors.margins: 11 text: panel.firmware.status - color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.textSoft + color: panel.firmware.targetVerified ? panel.theme.green : (panel.firmware.bootloaderHelpNeeded ? panel.theme.amber : panel.theme.textSoft) font.family: panel.uiFont - font.pixelSize: 9 + font.pixelSize: 10 wrapMode: Text.WordWrap verticalAlignment: Text.AlignVCenter } @@ -345,7 +363,7 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "Protocol, SHA-256 and raw flash log" + text: "Protocol, target revision, SHA-256 and flash log" color: panel.theme.muted font.family: panel.uiFont font.pixelSize: 9 @@ -404,7 +422,7 @@ SurfacePanel { color: panel.theme.textSoft selectionColor: panel.theme.accent font.family: panel.monoFont - font.pixelSize: 8 + font.pixelSize: 9 wrapMode: TextEdit.WrapAnywhere background: Rectangle { color: "#090e14"; radius: 6; border.width: 1; border.color: panel.theme.lineSoft } } From 835ba5206eb962d32467dda0e597ff05546c2428 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 14:21:11 +0700 Subject: [PATCH 050/272] test(studio): regress real espflash ESP32-P4 probe format --- apps/arstack_studio/src/main.cpp | 36 +++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 101fc6b6c..7766a5609 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -58,15 +58,45 @@ int checkReferenceTemplate(int argc, char* argv[]) { int checkFirmwareContract(int argc, char* argv[]) { QCoreApplication app(argc, argv); FirmwareManager firmware; + + int espflashMajor = -1; + int espflashMinor = -1; + const bool realEspflashFormat = FirmwareManager::parseEsp32P4Revision( + QStringLiteral("Chip type: esp32p4 (revision v1.3)\n"), + espflashMajor, + espflashMinor) && espflashMajor == 1 && espflashMinor == 3; + + int esptoolMajor = -1; + int esptoolMinor = -1; + const bool dashedFormat = FirmwareManager::parseEsp32P4Revision( + QStringLiteral("Chip type: ESP32-P4 (revision v1.0)\n"), + esptoolMajor, + esptoolMinor) && esptoolMajor == 1 && esptoolMinor == 0; + + int wrongMajor = -1; + int wrongMinor = -1; + const bool rejectsWrongChip = !FirmwareManager::parseEsp32P4Revision( + QStringLiteral("Chip type: esp32s3 (revision v0.2)\n"), + wrongMajor, + wrongMinor); + const bool valid = firmware.bundleReady() && firmware.flasherAvailable() && firmware.firmwareVersion() == QStringLiteral(ARSTACK_STUDIO_VERSION) && firmware.expectedProtocol() == QStringLiteral("1") && - firmware.firmwareSha256().size() == 64; + firmware.firmwareSha256().size() == 64 && + realEspflashFormat && dashedFormat && rejectsWrongChip; if (!valid) { - qCritical().noquote() << "Firmware bundle contract: FAIL ·" << firmware.bundleStatus(); + qCritical().noquote() + << "Firmware bundle/probe contract: FAIL ·" + << firmware.bundleStatus() + << "espflash-format=" << realEspflashFormat + << "dashed-format=" << dashedFormat + << "wrong-chip-rejected=" << rejectsWrongChip; return 4; } - qInfo().noquote() << "Firmware bundle contract: PASS ·" << firmware.bundleStatus(); + qInfo().noquote() + << "Firmware bundle/probe contract: PASS · real espflash P4 revision format accepted ·" + << firmware.bundleStatus(); return 0; } From bd082baa31ce4f38925524eafe3c968858b9de9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 14:44:53 +0700 Subject: [PATCH 051/272] feat(studio): auto-check single recovery COM port --- apps/arstack_studio/qml/FirmwarePanel.qml | 36 +++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index f6c4cfd81..b981d61fd 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -24,6 +24,24 @@ SurfacePanel { onTriggered: panel.device.autoDetectAndConnect() } + Timer { + id: autoProbeTimer + interval: 550 + repeat: false + onTriggered: { + if (panel.firmware.busy || panel.firmware.targetVerified) + return + if (panel.device.ports.length !== 1) + return + var port = panel.device.ports[0] + if (!port || port.length === 0) + return + if (panel.device.connected) + panel.device.disconnectPort() + panel.firmware.probeTarget(port) + } + } + Connections { target: panel.firmware function onInstallationFinished(resetSucceeded) { @@ -32,6 +50,20 @@ SurfacePanel { } } + Connections { + target: panel.device + function onPortsChanged() { + if (!panel.firmware.targetVerified && !panel.firmware.busy && panel.device.ports.length === 1) + autoProbeTimer.restart() + } + } + + Component.onCompleted: { + panel.device.refreshPorts() + if (panel.device.ports.length === 1) + autoProbeTimer.restart() + } + component StepBadge: Rectangle { property string stepText: "1" property bool complete: false @@ -218,7 +250,7 @@ SurfacePanel { text: panel.firmware.targetVerified ? panel.firmware.targetChip + " verified on " + panel.firmware.selectedPort : (panel.hasPort - ? firmwarePort.currentText + " is visible in Windows. Check the chip through the ESP32-P4 ROM bootloader." + ? firmwarePort.currentText + " is visible in Windows. Studio will check the ESP32-P4 automatically." : "Connect the ESP32-P4 programming USB cable, then click Refresh.") color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.muted font.family: panel.uiFont @@ -229,7 +261,7 @@ SurfacePanel { Label { visible: panel.firmware.bootloaderHelpNeeded Layout.fillWidth: true - text: "ROM not responding: hold BOOT → press and release RESET → release BOOT → click Check board again." + text: "ROM not responding: hold BOOT → press and release RESET → release BOOT → click Retry check." color: panel.theme.amber font.family: panel.uiFont font.pixelSize: 10 From 0c0673d3f827d10ca829a9839fa559e9a7bcfdfb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:18:48 +0700 Subject: [PATCH 052/272] feat(studio): make 4I4V startup zero-configuration --- apps/arstack_studio/qml/WorkflowBar.qml | 489 +++++++++--------------- 1 file changed, 189 insertions(+), 300 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 5686267ff..6f877073d 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -12,73 +12,115 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false - property int activeTab: 0 + readonly property bool p0FirmwareCompatible: device.deviceVerified && device.protocolVersion === "1" - readonly property bool deployReady: controller.canDeploy && p0FirmwareCompatible readonly property bool startReady: p0FirmwareCompatible && profiles.hasProfiles && controller.selectedProfileDeployable && device.profileArmed && !controller.profileDirty && !device.running && !device.profileDeploying - function deployReason() { - if (device.running) return "Stop SMV output before deploying a profile." - if (device.profileDeploying) return "Profile deployment is already in progress." - if (!device.deviceVerified) return "Install or recover ARStack firmware, then verify the ESP32-P4." - if (!p0FirmwareCompatible) return "Firmware protocol mismatch. Open Firmware setup to install the P0 firmware." - if (!profiles.hasProfiles) return "Load 4I+4V Quick Start or a compatible SCL first." - if (!controller.selectedProfileDeployable) return "The selected SCL stream is outside the P0 4I+4V device boundary." - return "Deploy the validated 4I+4V SV profile to the injector." + readonly property string smartState: { + if (device.running) return "RUNNING" + if (device.discovering || (device.connected && !device.deviceVerified)) return "CONNECTING" + if (!device.deviceVerified) return device.ports.length > 0 ? "DEVICE FOUND" : "WAITING FOR DEVICE" + if (!p0FirmwareCompatible) return "FIRMWARE UPDATE" + if (device.profileDeploying || controller.profileDirty || !device.profileArmed) return "PREPARING 4I+4V" + return "READY" + } + + readonly property color smartStateColor: { + if (smartState === "RUNNING" || smartState === "READY") return theme.green + if (smartState === "CONNECTING" || smartState === "PREPARING 4I+4V" || smartState === "FIRMWARE UPDATE") return theme.amber + if (smartState === "DEVICE FOUND") return theme.accent + return theme.muted + } + + function ensureDefaultProfile() { + if (profiles.referenceTemplateActive) + return true + if (!profiles.loadReferenceTemplate()) { + controller.showMessage(profiles.fatalError || "Unable to load the built-in 4I+4V profile.", true) + return false + } + controller.profileDirty = true + return true + } + + function prepareSmartSession() { + if (!device.deviceVerified || device.protocolVersion !== "1") + return + if (!ensureDefaultProfile()) + return + if (device.running) + return + if (!controller.selectedProfileDeployable) { + controller.showMessage("Built-in 4I+4V profile failed the device compatibility gate.", true) + return + } + if (controller.profileDirty || !device.profileArmed) { + controller.deploySelectedProfile() + return + } + controller.applyAllSignals() } function startReason() { if (device.running) return "SMV output is already running." - if (!device.deviceVerified) return "Install or recover ARStack firmware first." - if (!p0FirmwareCompatible) return "Firmware protocol mismatch. Install the P0 firmware first." - if (!profiles.hasProfiles) return "Load 4I+4V Quick Start or a compatible SCL first." - if (!controller.selectedProfileDeployable) return "Selected profile is not deployable on the P0 4I+4V runtime." - if (controller.profileDirty) return "Profile changed. Deploy it again before Start." - if (!device.profileArmed) return "Deploy the validated profile before Start." - if (device.profileDeploying) return "Wait for profile deployment to finish." + if (!device.deviceVerified) return device.ports.length > 0 + ? "Device detected. ARStack Studio is connecting automatically." + : "Connect the ESP32-P4; ARStack Studio will detect it automatically." + if (!p0FirmwareCompatible) return "Firmware update is required before injection." + if (device.profileDeploying || controller.profileDirty || !device.profileArmed) return "Preparing the default 4I+4V profile automatically…" + if (!controller.selectedProfileDeployable) return "The active profile is not compatible with this injector." return "Start 4I+4V Sampled Values output." } - implicitHeight: 94 - color: theme.surface2 - border.color: theme.line + Component.onCompleted: { + ensureDefaultProfile() + Qt.callLater(device.autoDetectAndConnect) + } - component RibbonTab: TabButton { - implicitWidth: text === "Engineering" ? 112 : 88 - implicitHeight: 31 - hoverEnabled: true - font.family: ribbon.uiFont - font.pixelSize: 10 - font.weight: checked ? Font.DemiBold : Font.Medium - contentItem: Label { - text: parent.text - color: parent.checked ? ribbon.theme.text : (parent.hovered ? ribbon.theme.textSoft : ribbon.theme.muted) - font: parent.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - Behavior on color { ColorAnimation { duration: 90 } } + Connections { + target: device + function onDeviceVerifiedChanged() { + if (device.deviceVerified) + prepareTimer.restart() } - background: Rectangle { - color: parent.hovered || parent.checked ? "#121b25" : "transparent" - radius: 6 - Behavior on color { ColorAnimation { duration: 90 } } - Rectangle { - visible: parent.parent.checked - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.leftMargin: 12 - anchors.rightMargin: 12 - height: 2 - radius: 1 - color: ribbon.theme.accent + function onProfileStateChanged() { + if (device.profileArmed && !device.profileDeploying) { + controller.profileDirty = false + controller.applyAllSignals() } } + function onRunningChanged() { + if (!device.running && device.deviceVerified && !device.profileArmed) + prepareTimer.restart() + } + } + + Timer { + id: prepareTimer + interval: 600 + repeat: false + onTriggered: ribbon.prepareSmartSession() } - component RibbonAction: CalmButton { + // Hot-plug/replug recovery. This never starts output automatically; it only + // discovers and verifies the device. START remains an explicit operator action. + Timer { + interval: 2500 + repeat: true + running: true + onTriggered: { + if (!device.deviceVerified && !device.discovering && !device.connected) + device.autoDetectAndConnect() + } + } + + implicitHeight: 94 + color: theme.surface2 + border.color: theme.line + + component ActionButton: CalmButton { theme: ribbon.theme uiFont: ribbon.uiFont implicitHeight: 40 @@ -86,279 +128,126 @@ SurfacePanel { font.pixelSize: 10 } - component RibbonDivider: Rectangle { - width: 1 - height: 28 - color: ribbon.theme.lineSoft - Layout.alignment: Qt.AlignVCenter - } - - component RailLabel: Label { - color: ribbon.theme.muted - font.family: ribbon.uiFont - font.pixelSize: 7 - font.weight: Font.DemiBold - font.letterSpacing: 0.35 - verticalAlignment: Text.AlignVCenter - } - ColumnLayout { anchors.fill: parent - spacing: 0 + anchors.leftMargin: 11 + anchors.rightMargin: 11 + anchors.topMargin: 8 + anchors.bottomMargin: 8 + spacing: 7 RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 32 - Layout.leftMargin: 9 - Layout.rightMargin: 9 - spacing: 2 - - RibbonTab { text: "Home"; checked: ribbon.activeTab === 0; onClicked: ribbon.activeTab = 0 } - RibbonTab { text: "View"; checked: ribbon.activeTab === 1; onClicked: ribbon.activeTab = 1 } - RibbonTab { text: "Engineering"; checked: ribbon.activeTab === 2; onClicked: ribbon.activeTab = 2 } - Item { Layout.fillWidth: true } + Layout.preferredHeight: 24 + spacing: 8 - RowLayout { + Rectangle { + width: 8 + height: 8 + radius: 4 + color: ribbon.smartStateColor + } + Label { + text: ribbon.smartState + color: ribbon.smartStateColor + font.family: ribbon.uiFont + font.pixelSize: 10 + font.weight: Font.Bold + font.letterSpacing: 0.45 + } + Label { visible: !ribbon.compact - spacing: 9 - RailLabel { - text: ribbon.device.deviceVerified ? "DEVICE · VERIFIED" : "DEVICE · SETUP" - color: ribbon.device.deviceVerified ? ribbon.theme.green : ribbon.theme.amber - } - Rectangle { width: 1; height: 11; color: ribbon.theme.lineSoft } - RailLabel { - text: ribbon.device.deviceVerified ? "FW · P" + ribbon.device.protocolVersion : "FW · INSTALL" - color: ribbon.p0FirmwareCompatible ? ribbon.theme.green : ribbon.theme.amber - } - Rectangle { width: 1; height: 11; color: ribbon.theme.lineSoft } - RailLabel { - text: ribbon.profiles.referenceTemplateActive ? "PROFILE · 4I+4V" : (ribbon.profiles.hasProfiles ? "PROFILE · SCL" : "PROFILE · —") - color: ribbon.profiles.hasProfiles ? ribbon.theme.textSoft : ribbon.theme.muted - } - Rectangle { width: 1; height: 11; color: ribbon.theme.lineSoft } - RailLabel { - text: ribbon.device.running ? "OUTPUT · RUNNING" : (ribbon.device.profileArmed && !ribbon.controller.profileDirty ? "OUTPUT · ARMED" : "OUTPUT · SAFE") - color: ribbon.device.running ? ribbon.theme.green : (ribbon.device.profileArmed ? ribbon.theme.accent : ribbon.theme.muted) - } + text: ribbon.smartState === "READY" || ribbon.smartState === "RUNNING" + ? "4I + 4V · 4000 samples/s · live value apply" + : (device.discoveryStatus.length ? device.discoveryStatus : "ARStack Studio is preparing the injector automatically") + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 9 + elide: Text.ElideRight + Layout.fillWidth: true + } + Item { Layout.fillWidth: ribbon.compact } + CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Advanced…" + implicitHeight: 28 + font.pixelSize: 9 + toolTipText: "Firmware, SCL, waveform stress, PTP and diagnostics" + onClicked: ribbon.controller.openConfiguration() } } - Rectangle { Layout.fillWidth: true; height: 1; color: ribbon.theme.lineSoft } - - StackLayout { + RowLayout { Layout.fillWidth: true Layout.fillHeight: true - currentIndex: ribbon.activeTab - - RowLayout { - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 6 - - RibbonAction { - text: ribbon.profiles.referenceTemplateActive ? "4I+4V Ready" : "4I+4V Quick Start" - iconSource: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") - tone: ribbon.profiles.referenceTemplateActive ? "accent" : "neutral" - enabled: !ribbon.device.running && !ribbon.device.profileDeploying - toolTipText: "Load the bundled ARStack 4I+4V 9-2LE reference profile at 4000 fps" - onClicked: { - if (ribbon.profiles.loadReferenceTemplate()) { - ribbon.controller.profileDirty = true - ribbon.controller.showMessage( - "4I+4V 9-2LE reference loaded · Class A · 4000 fps · smpCnt 0..3999.", - false) - } else { - ribbon.controller.showMessage( - ribbon.profiles.fatalError || "Unable to load the bundled reference profile.", - true) - } - } - } - - RibbonDivider {} - - RibbonAction { - text: "Balanced" - iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") - toolTipText: "Apply a balanced three-phase setpoint" - onClicked: ribbon.controller.balanced() - } - RibbonAction { - text: "Zero" - iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") - toolTipText: "Set all magnitudes to zero" - onClicked: ribbon.controller.zeroAll() - } - RibbonAction { - visible: !ribbon.compact - text: "CT Saturation" - iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") - tone: ribbon.controller.ctSaturationEnabled ? "accent" : "neutral" - enabled: ribbon.controller.signalFrequency > 0 - toolTipText: ribbon.controller.ctSaturationEnabled ? "Disable CT saturation stress" : "Enable CT saturation stress" - onClicked: ribbon.controller.setCtSaturation(!ribbon.controller.ctSaturationEnabled) - } - - RibbonDivider {} + spacing: 6 - RibbonAction { - visible: !ribbon.compact - text: "Check" - iconSource: Qt.resolvedUrl("../assets/lucide/circle-check.svg") - toolTipText: "Run readiness checks" - onClicked: ribbon.controller.runReadinessCheck() - } - RibbonAction { - text: ribbon.device.deviceVerified ? "Configuration" : "Install Firmware" - iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") - tone: ribbon.device.deviceVerified ? "neutral" : "accent" - toolTipText: ribbon.device.deviceVerified - ? "Open profile, firmware and expert configuration" - : "Open guided firmware setup for a blank or old-firmware ESP32-P4" - onClicked: ribbon.controller.openConfiguration() - } - - Item { Layout.fillWidth: true } - - RibbonDivider {} - - RibbonAction { - visible: !ribbon.compact - text: ribbon.device.profileDeploying ? "Deploying…" : "Deploy" - iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") - implicitWidth: 86 - enabled: ribbon.deployReady - toolTipText: ribbon.deployReason() - onClicked: ribbon.controller.deploySelectedProfile() - } - RibbonAction { - tone: "danger" - text: "Stop" - iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") - implicitWidth: 82 - enabled: ribbon.device.deviceVerified && ribbon.device.running - toolTipText: ribbon.device.running ? "Stop SMV output" : "Output is already stopped" - onClicked: ribbon.device.stop() - } - RibbonAction { - tone: "success" - text: "Start" - iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") - implicitWidth: 92 - enabled: ribbon.startReady - toolTipText: ribbon.startReason() - onClicked: ribbon.device.start() - } + ActionButton { + text: "Balanced" + iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") + toolTipText: "Apply balanced three-phase values" + onClicked: ribbon.controller.balanced() + } + ActionButton { + text: "Zero" + iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") + toolTipText: "Set every current and voltage magnitude to zero" + onClicked: ribbon.controller.zeroAll() } - RowLayout { - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 6 - - RibbonAction { - text: "Phasor" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") - tone: ribbon.controller.phasorDockVisible || ribbon.controller.phasorDetached ? "accent" : "neutral" - onClicked: { - ribbon.controller.phasorDetached = false - ribbon.controller.phasorDockVisible = !ribbon.controller.phasorDockVisible - } - } - RibbonAction { - text: "Waveform" - iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") - tone: ribbon.controller.waveformDockVisible || ribbon.controller.waveformDetached ? "accent" : "neutral" - onClicked: { - ribbon.controller.waveformDetached = false - ribbon.controller.waveformDockVisible = !ribbon.controller.waveformDockVisible - } - } - RibbonAction { - text: "Monitor" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") - tone: ribbon.controller.telemetryDockVisible ? "accent" : "neutral" - onClicked: ribbon.controller.telemetryDockVisible = !ribbon.controller.telemetryDockVisible - } - - RibbonDivider {} + Rectangle { width: 1; height: 28; color: ribbon.theme.lineSoft } - RibbonAction { - visible: !ribbon.compact - text: "Detach phasor" - iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") - enabled: ribbon.controller.phasorDockVisible && !ribbon.controller.phasorDetached - onClicked: ribbon.controller.detachPhasor() - } - RibbonAction { - visible: !ribbon.compact - text: "Detach waveform" - iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") - enabled: ribbon.controller.waveformDockVisible && !ribbon.controller.waveformDetached - onClicked: ribbon.controller.detachWaveform() + ActionButton { + visible: !ribbon.compact + text: "Phasor" + iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + tone: ribbon.controller.phasorDockVisible || ribbon.controller.phasorDetached ? "accent" : "neutral" + onClicked: { + ribbon.controller.phasorDetached = false + ribbon.controller.phasorDockVisible = !ribbon.controller.phasorDockVisible } - - Item { Layout.fillWidth: true } - - RibbonAction { - text: ribbon.controller.telemetryExpanded ? "Collapse monitor" : "Expand monitor" - iconSource: ribbon.controller.telemetryExpanded - ? Qt.resolvedUrl("../assets/lucide/chevron-down.svg") - : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") - onClicked: ribbon.controller.telemetryExpanded = !ribbon.controller.telemetryExpanded + } + ActionButton { + visible: !ribbon.compact + text: "Waveform" + iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") + tone: ribbon.controller.waveformDockVisible || ribbon.controller.waveformDetached ? "accent" : "neutral" + onClicked: { + ribbon.controller.waveformDetached = false + ribbon.controller.waveformDockVisible = !ribbon.controller.waveformDockVisible } } - RowLayout { - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 6 - - RibbonAction { - text: ribbon.device.deviceVerified ? "Configuration" : "Firmware Setup" - iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") - tone: ribbon.device.deviceVerified ? "neutral" : "accent" - onClicked: ribbon.controller.openConfiguration() - } - RibbonAction { - text: "Open SCL" - iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") - onClicked: ribbon.controller.openEngineeringFile() - } - RibbonAction { - text: "4I+4V Reference" - iconSource: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") - tone: ribbon.profiles.referenceTemplateActive ? "accent" : "neutral" - enabled: !ribbon.device.running && !ribbon.device.profileDeploying - onClicked: { - if (ribbon.profiles.loadReferenceTemplate()) { - ribbon.controller.profileDirty = true - ribbon.controller.showMessage("ARStack 4I+4V reference engineering profile loaded.", false) - } else { - ribbon.controller.showMessage(ribbon.profiles.fatalError, true) - } - } - } - RibbonAction { - text: "Detect device" - iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") - enabled: !ribbon.device.connected - onClicked: ribbon.device.autoDetectAndConnect() - } - RibbonAction { - text: "Refresh PTP" - iconSource: Qt.resolvedUrl("../assets/lucide/clock-3.svg") - enabled: ribbon.device.deviceVerified - onClicked: ribbon.device.sendPtpShow() - } + Item { Layout.fillWidth: true } - Item { Layout.fillWidth: true } + Label { + visible: ribbon.smartState === "PREPARING 4I+4V" + text: "Preparing…" + color: ribbon.theme.amber + font.family: ribbon.uiFont + font.pixelSize: 10 + } - RibbonAction { - text: "Diagnostics" - iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") - onClicked: ribbon.controller.openDiagnostics() - } + ActionButton { + visible: !device.running + text: "Start" + iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") + tone: "success" + implicitWidth: 132 + enabled: ribbon.startReady + toolTipText: ribbon.startReason() + onClicked: device.start() + } + ActionButton { + visible: device.running + text: "Stop" + iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") + tone: "danger" + implicitWidth: 132 + enabled: device.deviceVerified + toolTipText: "Stop Sampled Values output" + onClicked: device.stop() } } } From 4960b4290eee74d08f0ab9581940d094d3c012a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:19:01 +0700 Subject: [PATCH 053/272] feat(firmware): expose semantic version to Studio --- embedded/esp32p4_smv_injector/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/CMakeLists.txt b/embedded/esp32p4_smv_injector/CMakeLists.txt index de0fe8a80..d1ac04ce5 100644 --- a/embedded/esp32p4_smv_injector/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/CMakeLists.txt @@ -1,4 +1,8 @@ cmake_minimum_required(VERSION 3.16) +# Keep the device identity version aligned with the Studio P0 release bundle. +# ESP-IDF exports this as the application version through esp_app_desc. +set(PROJECT_VER "0.1.0") + include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(arstack_esp32p4_smv_injector) From 68ead2886eb3c7c0f556926d4248b92b87501cac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:20:09 +0700 Subject: [PATCH 054/272] fix(studio): serialize startup discovery and quiet default views --- apps/arstack_studio/qml/WorkflowBar.qml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 6f877073d..5a326d55c 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -75,8 +75,12 @@ SurfacePanel { } Component.onCompleted: { + // The application owns the normal profile and quiet default layout. + // Initial USB discovery remains serialized by Main's startup timer; + // the watchdog below only handles later hot-plug/replug events. ensureDefaultProfile() - Qt.callLater(device.autoDetectAndConnect) + controller.phasorDockVisible = false + controller.waveformDockVisible = false } Connections { From 17a6a94d47f47dbf5dd9ae4092504f1877e7772b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:21:10 +0700 Subject: [PATCH 055/272] perf(studio): coalesce live setpoint edits --- apps/arstack_studio/qml/SignalMatrix.qml | 36 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index e28b721eb..a72dc05ae 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -111,6 +111,8 @@ Rectangle { property bool selected: matrix.controller.activeSignal === signalRow.sid property alias magnitudeEditor: magnitudeField property alias phaseEditor: phaseField + property real pendingMagnitude: mag + property real pendingPhase: angle Layout.fillWidth: true Layout.preferredHeight: matrix.compact ? 50 : 54 @@ -121,6 +123,19 @@ Rectangle { HoverHandler { id: rowHover } + Timer { + id: magnitudeApplyTimer + interval: 90 + repeat: false + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", signalRow.pendingMagnitude) + } + Timer { + id: phaseApplyTimer + interval: 90 + repeat: false + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", signalRow.pendingPhase) + } + onMagChanged: if (!magnitudeField.activeFocus) magnitudeField.text = mag.toFixed(3) onAngleChanged: if (!phaseField.activeFocus) phaseField.text = angle.toFixed(2) @@ -156,12 +171,7 @@ Rectangle { Layout.preferredWidth: 34 Layout.alignment: Qt.AlignVCenter spacing: 5 - Rectangle { - width: 7 - height: 7 - radius: 3.5 - color: signalRow.phaseColor - } + Rectangle { width: 7; height: 7; radius: 3.5; color: signalRow.phaseColor } Label { text: signalRow.sid color: signalRow.selected ? matrix.theme.text : matrix.theme.textSoft @@ -192,7 +202,8 @@ Rectangle { var value = matrix.controller.parseOperatorNumber(text) if (matrix.controller.validMagnitude(matrix.groupIndex, value)) { invalidInput = false - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) + signalRow.pendingMagnitude = value + magnitudeApplyTimer.restart() } else { invalidInput = true } @@ -200,10 +211,14 @@ Rectangle { onEditingFinished: { var value = matrix.controller.parseOperatorNumber(text) if (!matrix.controller.validMagnitude(matrix.groupIndex, value)) { + magnitudeApplyTimer.stop() text = signalRow.mag.toFixed(3) invalidInput = false matrix.controller.showMessage(signalRow.sid + " magnitude is outside the valid wire/scaling range.", true) } else { + signalRow.pendingMagnitude = value + magnitudeApplyTimer.stop() + matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) text = value.toFixed(3) } } @@ -239,7 +254,8 @@ Rectangle { var value = matrix.controller.parseOperatorNumber(text) if (matrix.controller.validPhase(value)) { invalidInput = false - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) + signalRow.pendingPhase = value + phaseApplyTimer.restart() } else { invalidInput = true } @@ -247,10 +263,14 @@ Rectangle { onEditingFinished: { var value = matrix.controller.parseOperatorNumber(text) if (!matrix.controller.validPhase(value)) { + phaseApplyTimer.stop() text = signalRow.angle.toFixed(2) invalidInput = false matrix.controller.showMessage(signalRow.sid + " phase must stay within ±360000°.", true) } else { + signalRow.pendingPhase = value + phaseApplyTimer.stop() + matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) text = value.toFixed(2) } } From c7721a3f6f32306e3706616771d26a8f506d3cf8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:21:42 +0700 Subject: [PATCH 056/272] docs(studio): track zero-configuration UX progress --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/SMV_STUDIO_SMART_UX_PROGRESS.md diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md new file mode 100644 index 000000000..463be61bf --- /dev/null +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -0,0 +1,72 @@ +# ARStack Studio — P0 Smart UX / Zero-Configuration Progress + +Target operator experience: + +`Plug ESP32-P4 -> Studio detects/connects -> current firmware/default 4I+4V prepared -> READY -> edit values -> START/STOP` + +The normal operator must not need to understand SCL Class A, profile deployment, APPID/MAC/VLAN, firmware files, SHA-256, espflash, COM-port internals, or PTP unless an exceptional condition requires it. + +## Status legend + +- ✅ implemented in branch +- 🟡 implemented but hardware/UX acceptance still required +- ⬜ not implemented yet + +## Smart workflow + +- ✅ Built-in 4I+4V / 4000 fps / 9-2LE reference profile loads automatically. +- ✅ Existing startup device discovery remains automatic. +- ✅ Hot-plug/replug watchdog retries discovery while the device is genuinely offline. +- ✅ After a compatible injector is verified, the default 4I+4V profile is synchronized/deployed automatically while output is stopped. +- ✅ `Deploy`, `Check`, `Quick Start`, and normal Engineering tabs are removed from the primary operator workflow. +- ✅ START remains an explicit operator action; the application never auto-starts SV output. +- ✅ Normal workflow exposes one large START or STOP action, not both competing at once. +- ✅ Phasor and waveform views start collapsed; operator can opt in when useful. +- 🟡 Automatic profile preparation must be hardware-tested across app launch, USB replug, STOP/START, and ESP power-cycle. + +## Live values / responsiveness + +- ✅ Current and voltage edits already apply to the connected firmware live. +- ✅ Magnitude and phase text edits are coalesced with a 90 ms debounce to avoid serial command storms while preserving immediate-feeling operation. +- ⬜ Frequency editing still needs the same coalescing policy. +- ⬜ Add bounded native command queue / last-value-wins protection so correctness does not depend on QML timing. + +## Firmware intelligence + +- ✅ Firmware image, manifest, SHA-256 contract, and `espflash` are bundled with the application package. +- ✅ Recovery can probe and flash ESP32-P4 without ESP-IDF/Python on the operator PC. +- ✅ ROM/download-mode recovery has guided BOOT/RESET fallback. +- 🟡 ESP-IDF project version is now pinned to 0.1.0 as the basis for semantic firmware identity. +- ⬜ Firmware `IDENTIFY` response still needs to expose semantic firmware version/capabilities. +- ⬜ DeviceController still needs to parse/store semantic firmware version. +- ⬜ Current-vs-outdated firmware comparison is not implemented yet. +- ⬜ Friendly `Firmware update available -> Update now?` notification is not implemented yet. +- ⬜ One-click update needs automatic transition to ROM flashing, visible progress, verify/reset/reconnect, and fallback only when automatic bootloader entry fails. + +## Operator UI simplification + +- ✅ Primary ribbon reduced to smart state + Balanced + Zero + optional views + Advanced + START/STOP. +- ✅ Low-level profile deployment is hidden from normal operation. +- ✅ Advanced/recovery functionality remains available rather than being deleted. +- ⬜ Main workspace title and legacy header/footer language still need simplification. +- ⬜ Remove duplicate device/status indicators and legacy keyboard-help noise from normal view. +- ⬜ Advanced window needs final progressive-disclosure cleanup and larger minimum typography. +- ⬜ Review default window density at 100%, 125%, 150%, and laptop resolutions. + +## Robustness / crash resistance + +- ✅ Native START policy still fails closed unless verified protocol/profile state is safe. +- ✅ Profile deployment remains stopped-only and fail-closed. +- ✅ Firmware package/target/hash checks remain fail-closed. +- ✅ Startup discovery race was removed; initial probe and hot-plug watchdog are serialized by state. +- ⬜ Move smart-session orchestration from QML into one C++ state machine (`NO_DEVICE / CONNECTING / UPDATE_REQUIRED / PREPARING / READY / RUNNING / ERROR`). +- ⬜ Add bounded retry/backoff for disconnect/reconnect and serial errors. +- ⬜ Add regression tests for USB removal during READY and RUNNING. +- ⬜ Add regression tests for malformed/slow serial responses and command timeouts. +- ⬜ Add soak test for continuous 4000 fps operation plus repeated live edits. + +## Current checkpoint + +Checkpoint A — **zero-configuration normal path** is implemented in the branch and awaiting CI + hardware acceptance. + +Next implementation checkpoint: **semantic firmware identity + automatic update UX**, followed by moving session orchestration into native C++ and final visual cleanup. From f163c1a312b32acb6568091173b75fe5e07c67f2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:22:38 +0700 Subject: [PATCH 057/272] feat(studio): add native smart session controller --- .../src/SmartSessionController.hpp | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 apps/arstack_studio/src/SmartSessionController.hpp diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp new file mode 100644 index 000000000..e44d3732d --- /dev/null +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +class DeviceController; +class SclProfileModel; + +class SmartSessionController : public QObject { + Q_OBJECT + Q_PROPERTY(QObject* device READ device WRITE setDevice NOTIFY dependenciesChanged) + Q_PROPERTY(QObject* profiles READ profiles WRITE setProfiles NOTIFY dependenciesChanged) + Q_PROPERTY(QString state READ state NOTIFY stateChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) + Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) + Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) + +public: + explicit SmartSessionController(QObject* parent = nullptr); + + [[nodiscard]] QObject* device() const noexcept; + [[nodiscard]] QObject* profiles() const noexcept; + [[nodiscard]] QString state() const; + [[nodiscard]] QString statusText() const; + [[nodiscard]] bool startReady() const noexcept; + [[nodiscard]] bool firmwareUpdateRequired() const noexcept; + + void setDevice(QObject* object); + void setProfiles(QObject* object); + + Q_INVOKABLE void start(); + Q_INVOKABLE void reconcile(); + +signals: + void dependenciesChanged(); + void stateChanged(); + void readyForLiveApply(); + +private: + void reconnectDeviceSignals(); + void reconnectProfileSignals(); + bool ensureDefaultProfile(); + void setPresentation(QString state, QString status, bool startReady, bool firmwareUpdateRequired); + + DeviceController* device_{nullptr}; + SclProfileModel* profiles_{nullptr}; + QTimer discoveryTimer_; + QTimer prepareTimer_; + QString state_{QStringLiteral("WAITING FOR DEVICE")}; + QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; + bool startReady_{false}; + bool firmwareUpdateRequired_{false}; + bool started_{false}; + bool wasReady_{false}; +}; From 5ce3d87f4c5ac62d9307fd69bb7458f7e13fbadb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:23:04 +0700 Subject: [PATCH 058/272] feat(studio): implement native smart session orchestration --- .../src/SmartSessionController.cpp | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 apps/arstack_studio/src/SmartSessionController.cpp diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp new file mode 100644 index 000000000..9edcb4728 --- /dev/null +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SmartSessionController.hpp" + +#include "DeviceController.hpp" +#include "SclProfileModel.hpp" + +#include + +SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { + discoveryTimer_.setInterval(2500); + discoveryTimer_.setSingleShot(false); + connect(&discoveryTimer_, &QTimer::timeout, this, [this] { + if (!started_ || device_ == nullptr) return; + if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { + static_cast(device_->autoDetectAndConnect()); + } + }); + + prepareTimer_.setInterval(650); + prepareTimer_.setSingleShot(true); + connect(&prepareTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); +} + +QObject* SmartSessionController::device() const noexcept { return device_; } +QObject* SmartSessionController::profiles() const noexcept { return profiles_; } +QString SmartSessionController::state() const { return state_; } +QString SmartSessionController::statusText() const { return statusText_; } +bool SmartSessionController::startReady() const noexcept { return startReady_; } +bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } + +void SmartSessionController::setDevice(QObject* object) { + auto* next = qobject_cast(object); + if (device_ == next) return; + if (device_ != nullptr) disconnect(device_, nullptr, this, nullptr); + device_ = next; + reconnectDeviceSignals(); + emit dependenciesChanged(); + reconcile(); +} + +void SmartSessionController::setProfiles(QObject* object) { + auto* next = qobject_cast(object); + if (profiles_ == next) return; + if (profiles_ != nullptr) disconnect(profiles_, nullptr, this, nullptr); + profiles_ = next; + reconnectProfileSignals(); + emit dependenciesChanged(); + reconcile(); +} + +void SmartSessionController::start() { + if (started_) return; + started_ = true; + if (profiles_ != nullptr) static_cast(ensureDefaultProfile()); + discoveryTimer_.start(); + reconcile(); +} + +void SmartSessionController::reconnectDeviceSignals() { + if (device_ == nullptr) return; + + connect(device_, &DeviceController::deviceVerifiedChanged, this, [this] { + if (device_ != nullptr && device_->deviceVerified()) { + // Allow the already-requested SHOW / PROFILE SHOW replies to settle + // before changing a stopped profile. This avoids racing a reconnect + // against a publisher that was already running on the device. + prepareTimer_.start(); + } + reconcile(); + }); + connect(device_, &DeviceController::connectedChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::discoveryChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::portsChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::runningChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::profileStateChanged, this, &SmartSessionController::reconcile); +} + +void SmartSessionController::reconnectProfileSignals() { + if (profiles_ == nullptr) return; + connect(profiles_, &SclProfileModel::sourceChanged, this, &SmartSessionController::reconcile); + connect(profiles_, &SclProfileModel::selectedProfileChanged, this, &SmartSessionController::reconcile); +} + +bool SmartSessionController::ensureDefaultProfile() { + if (profiles_ == nullptr) return false; + if (profiles_->hasProfiles()) return true; + return profiles_->loadReferenceTemplate(); +} + +void SmartSessionController::setPresentation( + QString state, + QString status, + const bool startReady, + const bool firmwareUpdateRequired) { + const bool readyNow = startReady; + const bool changed = state_ != state || statusText_ != status || + startReady_ != startReady || firmwareUpdateRequired_ != firmwareUpdateRequired; + + state_ = std::move(state); + statusText_ = std::move(status); + startReady_ = startReady; + firmwareUpdateRequired_ = firmwareUpdateRequired; + + if (changed) emit stateChanged(); + if (readyNow && !wasReady_) emit readyForLiveApply(); + wasReady_ = readyNow; +} + +void SmartSessionController::reconcile() { + if (device_ == nullptr || profiles_ == nullptr) { + setPresentation( + QStringLiteral("INITIALIZING"), + QStringLiteral("Preparing ARStack Studio…"), + false, + false); + return; + } + + if (!profiles_->hasProfiles() && !ensureDefaultProfile()) { + setPresentation( + QStringLiteral("SETUP ERROR"), + profiles_->fatalError().isEmpty() + ? QStringLiteral("The built-in 4I+4V profile could not be loaded.") + : profiles_->fatalError(), + false, + false); + return; + } + + if (device_->running()) { + setPresentation( + QStringLiteral("RUNNING"), + QStringLiteral("4I + 4V · 4000 samples/s · live value apply"), + false, + false); + return; + } + + if (device_->discovering() || (device_->connected() && !device_->deviceVerified())) { + setPresentation( + QStringLiteral("CONNECTING"), + device_->discoveryStatus(), + false, + false); + return; + } + + if (!device_->deviceVerified()) { + setPresentation( + device_->ports().isEmpty() ? QStringLiteral("WAITING FOR DEVICE") : QStringLiteral("DEVICE FOUND"), + device_->ports().isEmpty() + ? QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.") + : QStringLiteral("USB device found. ARStack Studio will connect automatically."), + false, + false); + return; + } + + if (device_->protocolVersion() != QStringLiteral("1")) { + setPresentation( + QStringLiteral("FIRMWARE UPDATE"), + QStringLiteral("The connected board needs compatible ARStack firmware before injection."), + false, + true); + return; + } + + const QVariantMap profile = profiles_->selectedProfile(); + const bool deployable = profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && + profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready"); + if (!deployable) { + setPresentation( + QStringLiteral("PROFILE BLOCKED"), + QStringLiteral("The selected profile is outside the supported 4I+4V runtime."), + false, + false); + return; + } + + if (device_->profileDeploying() || prepareTimer_.isActive()) { + setPresentation( + QStringLiteral("PREPARING 4I+4V"), + QStringLiteral("Preparing the default 4I+4V injection profile…"), + false, + false); + return; + } + + if (!device_->profileArmed()) { + setPresentation( + QStringLiteral("PREPARING 4I+4V"), + QStringLiteral("Synchronizing the default 4I+4V profile…"), + false, + false); + static_cast(device_->deployProfile(profile)); + return; + } + + setPresentation( + QStringLiteral("READY"), + QStringLiteral("4I + 4V · 4000 samples/s · ready to start"), + true, + false); +} From cff5fbca1c8d34392a7620a55d5b17cae9af7d73 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:23:18 +0700 Subject: [PATCH 059/272] build(studio): register smart session controller --- apps/arstack_studio/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 1d95df735..e5bb50256 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -26,6 +26,8 @@ qt_add_executable(arstack_studio src/StudioDeviceController.hpp src/FirmwareManager.cpp src/FirmwareManager.hpp + src/SmartSessionController.cpp + src/SmartSessionController.hpp ) qt_add_qml_module(arstack_studio From cb9e56c9c741df35de6a3a0a32b2c08a1c355750 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:23:41 +0700 Subject: [PATCH 060/272] feat(studio): expose native smart session to QML --- apps/arstack_studio/src/main.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 7766a5609..0c1d8e4d7 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -2,6 +2,7 @@ #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" +#include "SmartSessionController.hpp" #include "StudioDeviceController.hpp" #include @@ -135,6 +136,7 @@ int main(int argc, char* argv[]) { qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); qmlRegisterType("ARStack.Studio", 1, 0, "FirmwareManager"); + qmlRegisterType("ARStack.Studio", 1, 0, "SmartSessionController"); QQmlApplicationEngine engine; QObject::connect( From fba46c72e8a88a01fb02ce149f38e0ac06e59b7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:24:01 +0700 Subject: [PATCH 061/272] refactor(studio): drive operator UI from native smart session --- apps/arstack_studio/qml/WorkflowBar.qml | 127 ++++++------------------ 1 file changed, 28 insertions(+), 99 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 5a326d55c..242beabb8 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ARStack.Studio 1.0 SurfacePanel { id: ribbon @@ -13,111 +14,41 @@ SurfacePanel { property string monoFont: "Inter" property bool compact: false - readonly property bool p0FirmwareCompatible: device.deviceVerified && device.protocolVersion === "1" - readonly property bool startReady: - p0FirmwareCompatible && profiles.hasProfiles && controller.selectedProfileDeployable && - device.profileArmed && !controller.profileDirty && !device.running && !device.profileDeploying - - readonly property string smartState: { - if (device.running) return "RUNNING" - if (device.discovering || (device.connected && !device.deviceVerified)) return "CONNECTING" - if (!device.deviceVerified) return device.ports.length > 0 ? "DEVICE FOUND" : "WAITING FOR DEVICE" - if (!p0FirmwareCompatible) return "FIRMWARE UPDATE" - if (device.profileDeploying || controller.profileDirty || !device.profileArmed) return "PREPARING 4I+4V" - return "READY" - } - - readonly property color smartStateColor: { - if (smartState === "RUNNING" || smartState === "READY") return theme.green - if (smartState === "CONNECTING" || smartState === "PREPARING 4I+4V" || smartState === "FIRMWARE UPDATE") return theme.amber - if (smartState === "DEVICE FOUND") return theme.accent - return theme.muted - } - - function ensureDefaultProfile() { - if (profiles.referenceTemplateActive) - return true - if (!profiles.loadReferenceTemplate()) { - controller.showMessage(profiles.fatalError || "Unable to load the built-in 4I+4V profile.", true) - return false - } - controller.profileDirty = true - return true - } - - function prepareSmartSession() { - if (!device.deviceVerified || device.protocolVersion !== "1") - return - if (!ensureDefaultProfile()) - return - if (device.running) - return - if (!controller.selectedProfileDeployable) { - controller.showMessage("Built-in 4I+4V profile failed the device compatibility gate.", true) - return - } - if (controller.profileDirty || !device.profileArmed) { - controller.deploySelectedProfile() - return - } - controller.applyAllSignals() - } - - function startReason() { - if (device.running) return "SMV output is already running." - if (!device.deviceVerified) return device.ports.length > 0 - ? "Device detected. ARStack Studio is connecting automatically." - : "Connect the ESP32-P4; ARStack Studio will detect it automatically." - if (!p0FirmwareCompatible) return "Firmware update is required before injection." - if (device.profileDeploying || controller.profileDirty || !device.profileArmed) return "Preparing the default 4I+4V profile automatically…" - if (!controller.selectedProfileDeployable) return "The active profile is not compatible with this injector." - return "Start 4I+4V Sampled Values output." + SmartSessionController { + id: smartSession + device: ribbon.device + profiles: ribbon.profiles } Component.onCompleted: { - // The application owns the normal profile and quiet default layout. - // Initial USB discovery remains serialized by Main's startup timer; - // the watchdog below only handles later hot-plug/replug events. - ensureDefaultProfile() controller.phasorDockVisible = false controller.waveformDockVisible = false + smartSession.start() } Connections { - target: device - function onDeviceVerifiedChanged() { - if (device.deviceVerified) - prepareTimer.restart() - } - function onProfileStateChanged() { - if (device.profileArmed && !device.profileDeploying) { - controller.profileDirty = false - controller.applyAllSignals() - } - } - function onRunningChanged() { - if (!device.running && device.deviceVerified && !device.profileArmed) - prepareTimer.restart() + target: smartSession + function onReadyForLiveApply() { + controller.profileDirty = false + controller.applyAllSignals() } } - Timer { - id: prepareTimer - interval: 600 - repeat: false - onTriggered: ribbon.prepareSmartSession() + readonly property color smartStateColor: { + if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green + if (smartSession.state === "CONNECTING" || smartSession.state === "PREPARING 4I+4V" || smartSession.state === "FIRMWARE UPDATE") return theme.amber + if (smartSession.state === "DEVICE FOUND") return theme.accent + if (smartSession.state === "PROFILE BLOCKED" || smartSession.state === "SETUP ERROR") return theme.red + return theme.muted } - // Hot-plug/replug recovery. This never starts output automatically; it only - // discovers and verifies the device. START remains an explicit operator action. - Timer { - interval: 2500 - repeat: true - running: true - onTriggered: { - if (!device.deviceVerified && !device.discovering && !device.connected) - device.autoDetectAndConnect() - } + function startReason() { + if (device.running) return "SMV output is already running." + if (smartSession.firmwareUpdateRequired) return "Firmware update is required before injection." + if (smartSession.state === "CONNECTING") return "ARStack Studio is connecting automatically." + if (smartSession.state === "PREPARING 4I+4V") return "Preparing the default 4I+4V profile automatically…" + if (smartSession.state === "WAITING FOR DEVICE") return "Connect the ESP32-P4; ARStack Studio will detect it automatically." + return smartSession.statusText } implicitHeight: 94 @@ -152,7 +83,7 @@ SurfacePanel { color: ribbon.smartStateColor } Label { - text: ribbon.smartState + text: smartSession.state color: ribbon.smartStateColor font.family: ribbon.uiFont font.pixelSize: 10 @@ -161,9 +92,7 @@ SurfacePanel { } Label { visible: !ribbon.compact - text: ribbon.smartState === "READY" || ribbon.smartState === "RUNNING" - ? "4I + 4V · 4000 samples/s · live value apply" - : (device.discoveryStatus.length ? device.discoveryStatus : "ARStack Studio is preparing the injector automatically") + text: smartSession.statusText color: ribbon.theme.muted font.family: ribbon.uiFont font.pixelSize: 9 @@ -226,8 +155,8 @@ SurfacePanel { Item { Layout.fillWidth: true } Label { - visible: ribbon.smartState === "PREPARING 4I+4V" - text: "Preparing…" + visible: smartSession.state === "CONNECTING" || smartSession.state === "PREPARING 4I+4V" + text: smartSession.state === "CONNECTING" ? "Connecting…" : "Preparing…" color: ribbon.theme.amber font.family: ribbon.uiFont font.pixelSize: 10 @@ -239,7 +168,7 @@ SurfacePanel { iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") tone: "success" implicitWidth: 132 - enabled: ribbon.startReady + enabled: smartSession.startReady toolTipText: ribbon.startReason() onClicked: device.start() } From aed2c85ebb329ebd1115df3b4faec0247c441038 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:24:27 +0700 Subject: [PATCH 062/272] docs(studio): mark native smart-session checkpoint --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index 463be61bf..52acef121 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -22,6 +22,7 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ START remains an explicit operator action; the application never auto-starts SV output. - ✅ Normal workflow exposes one large START or STOP action, not both competing at once. - ✅ Phasor and waveform views start collapsed; operator can opt in when useful. +- ✅ Normal session orchestration now lives in native `SmartSessionController` instead of ribbon timing logic. - 🟡 Automatic profile preparation must be hardware-tested across app launch, USB replug, STOP/START, and ESP power-cycle. ## Live values / responsiveness @@ -48,6 +49,7 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Primary ribbon reduced to smart state + Balanced + Zero + optional views + Advanced + START/STOP. - ✅ Low-level profile deployment is hidden from normal operation. - ✅ Advanced/recovery functionality remains available rather than being deleted. +- ✅ Phasor/waveform visual noise is opt-in at startup rather than occupying the default workspace. - ⬜ Main workspace title and legacy header/footer language still need simplification. - ⬜ Remove duplicate device/status indicators and legacy keyboard-help noise from normal view. - ⬜ Advanced window needs final progressive-disclosure cleanup and larger minimum typography. @@ -59,7 +61,8 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Profile deployment remains stopped-only and fail-closed. - ✅ Firmware package/target/hash checks remain fail-closed. - ✅ Startup discovery race was removed; initial probe and hot-plug watchdog are serialized by state. -- ⬜ Move smart-session orchestration from QML into one C++ state machine (`NO_DEVICE / CONNECTING / UPDATE_REQUIRED / PREPARING / READY / RUNNING / ERROR`). +- ✅ Smart-session orchestration moved into one native C++ state machine (`WAITING FOR DEVICE / DEVICE FOUND / CONNECTING / FIRMWARE UPDATE / PREPARING 4I+4V / READY / RUNNING / ERROR`). +- ✅ Reconnect preparation is delayed briefly after identity verification so existing SHOW / PROFILE SHOW responses can settle before an automatic deploy. - ⬜ Add bounded retry/backoff for disconnect/reconnect and serial errors. - ⬜ Add regression tests for USB removal during READY and RUNNING. - ⬜ Add regression tests for malformed/slow serial responses and command timeouts. @@ -67,6 +70,6 @@ The normal operator must not need to understand SCL Class A, profile deployment, ## Current checkpoint -Checkpoint A — **zero-configuration normal path** is implemented in the branch and awaiting CI + hardware acceptance. +Checkpoint A — **zero-configuration normal path + native smart-session orchestration** is implemented in the branch and awaiting CI + hardware acceptance. -Next implementation checkpoint: **semantic firmware identity + automatic update UX**, followed by moving session orchestration into native C++ and final visual cleanup. +Next implementation checkpoint: **semantic firmware identity + automatic update UX**, followed by native command-queue hardening and final visual cleanup. From 24db256cf2aa52f41f5ba6a21e09e03f400eddfa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:24:58 +0700 Subject: [PATCH 063/272] fix(studio): include move utility for smart session --- apps/arstack_studio/src/SmartSessionController.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 9edcb4728..e9243aa58 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -7,6 +7,8 @@ #include +#include + SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { discoveryTimer_.setInterval(2500); discoveryTimer_.setSingleShot(false); From a2f93c0f980159e3994c5a7ce9bccacae2cf6184 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:25:38 +0700 Subject: [PATCH 064/272] fix(studio): require session-owned profile synchronization --- apps/arstack_studio/src/SmartSessionController.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index e44d3732d..94a5b99f6 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -53,4 +53,6 @@ class SmartSessionController : public QObject { bool firmwareUpdateRequired_{false}; bool started_{false}; bool wasReady_{false}; + bool needsProfileSync_{true}; + bool profileSyncInFlight_{false}; }; From dfdbc39aea85e0a9ddf90ca95cd321cc5f4ff259 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:26:13 +0700 Subject: [PATCH 065/272] fix(studio): do not trust stale armed profile across sessions --- .../src/SmartSessionController.cpp | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index e9243aa58..9543922b2 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -36,6 +36,8 @@ void SmartSessionController::setDevice(QObject* object) { if (device_ == next) return; if (device_ != nullptr) disconnect(device_, nullptr, this, nullptr); device_ = next; + needsProfileSync_ = true; + profileSyncInFlight_ = false; reconnectDeviceSignals(); emit dependenciesChanged(); reconcile(); @@ -46,6 +48,8 @@ void SmartSessionController::setProfiles(QObject* object) { if (profiles_ == next) return; if (profiles_ != nullptr) disconnect(profiles_, nullptr, this, nullptr); profiles_ = next; + needsProfileSync_ = true; + profileSyncInFlight_ = false; reconnectProfileSignals(); emit dependenciesChanged(); reconcile(); @@ -64,10 +68,15 @@ void SmartSessionController::reconnectDeviceSignals() { connect(device_, &DeviceController::deviceVerifiedChanged, this, [this] { if (device_ != nullptr && device_->deviceVerified()) { + needsProfileSync_ = true; + profileSyncInFlight_ = false; // Allow the already-requested SHOW / PROFILE SHOW replies to settle // before changing a stopped profile. This avoids racing a reconnect // against a publisher that was already running on the device. prepareTimer_.start(); + } else { + needsProfileSync_ = true; + profileSyncInFlight_ = false; } reconcile(); }); @@ -75,13 +84,28 @@ void SmartSessionController::reconnectDeviceSignals() { connect(device_, &DeviceController::discoveryChanged, this, &SmartSessionController::reconcile); connect(device_, &DeviceController::portsChanged, this, &SmartSessionController::reconcile); connect(device_, &DeviceController::runningChanged, this, &SmartSessionController::reconcile); - connect(device_, &DeviceController::profileStateChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::profileStateChanged, this, [this] { + if (device_ != nullptr && profileSyncInFlight_ && + device_->profileArmed() && !device_->profileDeploying()) { + profileSyncInFlight_ = false; + needsProfileSync_ = false; + } + reconcile(); + }); } void SmartSessionController::reconnectProfileSignals() { if (profiles_ == nullptr) return; - connect(profiles_, &SclProfileModel::sourceChanged, this, &SmartSessionController::reconcile); - connect(profiles_, &SclProfileModel::selectedProfileChanged, this, &SmartSessionController::reconcile); + connect(profiles_, &SclProfileModel::sourceChanged, this, [this] { + needsProfileSync_ = true; + profileSyncInFlight_ = false; + reconcile(); + }); + connect(profiles_, &SclProfileModel::selectedProfileChanged, this, [this] { + needsProfileSync_ = true; + profileSyncInFlight_ = false; + reconcile(); + }); } bool SmartSessionController::ensureDefaultProfile() { @@ -189,13 +213,28 @@ void SmartSessionController::reconcile() { return; } - if (!device_->profileArmed()) { + if (needsProfileSync_) { setPresentation( QStringLiteral("PREPARING 4I+4V"), QStringLiteral("Synchronizing the default 4I+4V profile…"), false, false); - static_cast(device_->deployProfile(profile)); + if (!profileSyncInFlight_) { + profileSyncInFlight_ = device_->deployProfile(profile); + } + return; + } + + if (!device_->profileArmed()) { + // An unexpected de-arm after a successful session-owned sync must be + // recovered by another controlled sync before START can unlock. + needsProfileSync_ = true; + profileSyncInFlight_ = false; + setPresentation( + QStringLiteral("PREPARING 4I+4V"), + QStringLiteral("Restoring the default 4I+4V profile…"), + false, + false); return; } From e108ce23bc2432666a336f83f81afeded685937c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:29:38 +0700 Subject: [PATCH 066/272] feat(firmware): publish semantic version and capabilities --- embedded/esp32p4_smv_injector/main/live_control.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 406a07269..d6efa033f 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -4,6 +4,7 @@ #include "ethernet_port.h" #include "profile_control.hpp" +#include "esp_app_desc.h" #include "esp_log.h" #include "esp_mac.h" #include "freertos/FreeRTOS.h" @@ -119,11 +120,14 @@ void print_identity() noexcept { ESP_LOGE(kTag, "ARSTACK identity unavailable: factory device ID read failed"); return; } + const esp_app_desc_t* app = esp_app_get_description(); + const char* version = app != nullptr ? app->version : "unknown"; ESP_LOGI(kTag, - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X", + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s capabilities=SMV-4I4V,LIVE-SETPOINTS", static_cast(device_id[0]), static_cast(device_id[1]), static_cast(device_id[2]), static_cast(device_id[3]), - static_cast(device_id[4]), static_cast(device_id[5])); + static_cast(device_id[4]), static_cast(device_id[5]), + version); } void print_ptp_state() noexcept { From 330e829ca9e5034bfe29b70f30621082617ab3fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:30:40 +0700 Subject: [PATCH 067/272] feat(studio): add native firmware update coordinator --- .../src/SmartSessionController.hpp | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 94a5b99f6..dbe16b582 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -5,6 +5,7 @@ #include class DeviceController; +class FirmwareManager; class SclProfileModel; class SmartSessionController : public QObject { @@ -15,6 +16,11 @@ class SmartSessionController : public QObject { Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) + Q_PROPERTY(bool updatingFirmware READ updatingFirmware NOTIFY stateChanged) + Q_PROPERTY(bool updateNeedsBootloaderHelp READ updateNeedsBootloaderHelp NOTIFY stateChanged) + Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged) + Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT) + Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged) public: explicit SmartSessionController(QObject* parent = nullptr); @@ -25,34 +31,53 @@ class SmartSessionController : public QObject { [[nodiscard]] QString statusText() const; [[nodiscard]] bool startReady() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; + [[nodiscard]] bool updatingFirmware() const noexcept; + [[nodiscard]] bool updateNeedsBootloaderHelp() const noexcept; + [[nodiscard]] QString updateStatus() const; + [[nodiscard]] QString expectedFirmwareVersion() const; + [[nodiscard]] QString deviceFirmwareVersion() const; void setDevice(QObject* object); void setProfiles(QObject* object); Q_INVOKABLE void start(); Q_INVOKABLE void reconcile(); + Q_INVOKABLE bool beginFirmwareUpdate(); + Q_INVOKABLE bool retryFirmwareUpdate(); signals: void dependenciesChanged(); void stateChanged(); void readyForLiveApply(); + void firmwareUpdateFinished(bool success); private: + enum class UpdateStage { idle, stopping, probing, flashing, reconnecting, waitingForBootloader }; + void reconnectDeviceSignals(); void reconnectProfileSignals(); bool ensureDefaultProfile(); void setPresentation(QString state, QString status, bool startReady, bool firmwareUpdateRequired); + void refreshFirmwareIdentity(); + void continueFirmwareUpdate(); + bool firmwareIsCurrent() const; DeviceController* device_{nullptr}; SclProfileModel* profiles_{nullptr}; + FirmwareManager* firmware_{nullptr}; QTimer discoveryTimer_; QTimer prepareTimer_; + QTimer reconnectTimer_; QString state_{QStringLiteral("WAITING FOR DEVICE")}; QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; + QString deviceFirmwareVersion_; + QString updatePort_; bool startReady_{false}; bool firmwareUpdateRequired_{false}; bool started_{false}; bool wasReady_{false}; bool needsProfileSync_{true}; bool profileSyncInFlight_{false}; + bool updateRequested_{false}; + UpdateStage updateStage_{UpdateStage::idle}; }; From f1eafa50c9023cc93864e59418588843b7e7aafe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:31:57 +0700 Subject: [PATCH 068/272] feat(studio): orchestrate one-click firmware update --- .../src/SmartSessionController.cpp | 252 +++++++++++++++++- 1 file changed, 241 insertions(+), 11 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 9543922b2..cb0fee3c9 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -3,17 +3,25 @@ #include "SmartSessionController.hpp" #include "DeviceController.hpp" +#include "FirmwareManager.hpp" #include "SclProfileModel.hpp" +#include #include #include +#ifndef ARSTACK_STUDIO_VERSION +#define ARSTACK_STUDIO_VERSION "0.1.0" +#endif + SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { + firmware_ = new FirmwareManager(this); + discoveryTimer_.setInterval(2500); discoveryTimer_.setSingleShot(false); connect(&discoveryTimer_, &QTimer::timeout, this, [this] { - if (!started_ || device_ == nullptr) return; + if (!started_ || device_ == nullptr || updateRequested_) return; if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { static_cast(device_->autoDetectAndConnect()); } @@ -22,6 +30,68 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent prepareTimer_.setInterval(650); prepareTimer_.setSingleShot(true); connect(&prepareTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); + + reconnectTimer_.setInterval(1800); + reconnectTimer_.setSingleShot(true); + connect(&reconnectTimer_, &QTimer::timeout, this, [this] { + if (device_ == nullptr || updateStage_ != UpdateStage::reconnecting) return; + static_cast(device_->autoDetectAndConnect()); + reconcile(); + }); + + connect(firmware_, &FirmwareManager::stateChanged, this, [this] { + if (!updateRequested_) { + reconcile(); + return; + } + + if (updateStage_ == UpdateStage::probing && !firmware_->busy()) { + if (firmware_->targetVerified()) { + updateStage_ = UpdateStage::flashing; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Installing verified ARStack firmware…"), + false, + false); + QTimer::singleShot(0, this, [this] { + if (!firmware_->installFirmware(updatePort_)) { + updateStage_ = firmware_->bootloaderHelpNeeded() + ? UpdateStage::waitingForBootloader + : UpdateStage::idle; + if (updateStage_ == UpdateStage::idle) updateRequested_ = false; + reconcile(); + } + }); + return; + } + if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } + } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy() && + firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } + reconcile(); + }); + + connect(firmware_, &FirmwareManager::installationFinished, this, [this](const bool resetSucceeded) { + if (!updateRequested_) return; + if (resetSucceeded) { + updateStage_ = UpdateStage::reconnecting; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Firmware installed. Reconnecting to ESP32-P4…"), + false, + false); + reconnectTimer_.start(); + } else if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + reconcile(); + } else { + updateStage_ = UpdateStage::reconnecting; + reconnectTimer_.start(); + } + }); } QObject* SmartSessionController::device() const noexcept { return device_; } @@ -30,12 +100,40 @@ QString SmartSessionController::state() const { return state_; } QString SmartSessionController::statusText() const { return statusText_; } bool SmartSessionController::startReady() const noexcept { return startReady_; } bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } +bool SmartSessionController::updatingFirmware() const noexcept { + return updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::probing || + updateStage_ == UpdateStage::flashing || updateStage_ == UpdateStage::reconnecting; +} +bool SmartSessionController::updateNeedsBootloaderHelp() const noexcept { + return updateStage_ == UpdateStage::waitingForBootloader; +} +QString SmartSessionController::updateStatus() const { + if (firmware_ == nullptr) return {}; + switch (updateStage_) { + case UpdateStage::stopping: + return QStringLiteral("Stopping SMV output safely…"); + case UpdateStage::probing: + return firmware_->status(); + case UpdateStage::flashing: + return firmware_->status(); + case UpdateStage::reconnecting: + return QStringLiteral("Firmware installed. Waiting for ESP32-P4 to reconnect…"); + case UpdateStage::waitingForBootloader: + return QStringLiteral("Hold BOOT, press and release RESET, release BOOT, then Retry update."); + case UpdateStage::idle: + return {}; + } + return {}; +} +QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); } +QString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; } void SmartSessionController::setDevice(QObject* object) { auto* next = qobject_cast(object); if (device_ == next) return; if (device_ != nullptr) disconnect(device_, nullptr, this, nullptr); device_ = next; + deviceFirmwareVersion_.clear(); needsProfileSync_ = true; profileSyncInFlight_ = false; reconnectDeviceSignals(); @@ -63,18 +161,88 @@ void SmartSessionController::start() { reconcile(); } +bool SmartSessionController::beginFirmwareUpdate() { + if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || + !firmware_->bundleReady() || firmware_->busy()) { + return false; + } + const QString port = device_->portName().trimmed(); + if (port.isEmpty()) return false; + + updatePort_ = port; + updateRequested_ = true; + needsProfileSync_ = true; + profileSyncInFlight_ = false; + + if (device_->running()) { + updateStage_ = UpdateStage::stopping; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Stopping SMV output before firmware update…"), + false, + false); + if (!device_->stop()) { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + reconcile(); + return false; + } + return true; + } + + continueFirmwareUpdate(); + return true; +} + +bool SmartSessionController::retryFirmwareUpdate() { + if (firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; + updateRequested_ = true; + if (device_ != nullptr && device_->connected()) device_->disconnectPort(); + updateStage_ = UpdateStage::probing; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Checking ESP32-P4 bootloader…"), + false, + false); + if (!firmware_->probeTarget(updatePort_)) { + updateStage_ = UpdateStage::waitingForBootloader; + reconcile(); + return false; + } + return true; +} + +void SmartSessionController::continueFirmwareUpdate() { + if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; + if (device_->running()) { + updateStage_ = UpdateStage::stopping; + return; + } + + if (device_->connected()) device_->disconnectPort(); + updateStage_ = UpdateStage::probing; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Checking ESP32-P4 and preparing firmware…"), + false, + false); + if (!firmware_->probeTarget(updatePort_)) { + updateStage_ = UpdateStage::waitingForBootloader; + reconcile(); + } +} + void SmartSessionController::reconnectDeviceSignals() { if (device_ == nullptr) return; connect(device_, &DeviceController::deviceVerifiedChanged, this, [this] { + refreshFirmwareIdentity(); if (device_ != nullptr && device_->deviceVerified()) { needsProfileSync_ = true; profileSyncInFlight_ = false; - // Allow the already-requested SHOW / PROFILE SHOW replies to settle - // before changing a stopped profile. This avoids racing a reconnect - // against a publisher that was already running on the device. prepareTimer_.start(); } else { + deviceFirmwareVersion_.clear(); needsProfileSync_ = true; profileSyncInFlight_ = false; } @@ -83,7 +251,17 @@ void SmartSessionController::reconnectDeviceSignals() { connect(device_, &DeviceController::connectedChanged, this, &SmartSessionController::reconcile); connect(device_, &DeviceController::discoveryChanged, this, &SmartSessionController::reconcile); connect(device_, &DeviceController::portsChanged, this, &SmartSessionController::reconcile); - connect(device_, &DeviceController::runningChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::logTextChanged, this, [this] { + refreshFirmwareIdentity(); + reconcile(); + }); + connect(device_, &DeviceController::runningChanged, this, [this] { + if (updateRequested_ && updateStage_ == UpdateStage::stopping && + device_ != nullptr && !device_->running()) { + QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); + } + reconcile(); + }); connect(device_, &DeviceController::profileStateChanged, this, [this] { if (device_ != nullptr && profileSyncInFlight_ && device_->profileArmed() && !device_->profileDeploying()) { @@ -99,12 +277,12 @@ void SmartSessionController::reconnectProfileSignals() { connect(profiles_, &SclProfileModel::sourceChanged, this, [this] { needsProfileSync_ = true; profileSyncInFlight_ = false; - reconcile(); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); }); connect(profiles_, &SclProfileModel::selectedProfileChanged, this, [this] { needsProfileSync_ = true; profileSyncInFlight_ = false; - reconcile(); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); }); } @@ -133,6 +311,30 @@ void SmartSessionController::setPresentation( wasReady_ = readyNow; } +void SmartSessionController::refreshFirmwareIdentity() { + if (device_ == nullptr || !device_->deviceVerified()) return; + const QString log = device_->logText(); + const qsizetype identityPos = log.lastIndexOf(QStringLiteral("ARSTACK identity"), -1, Qt::CaseInsensitive); + if (identityPos < 0) return; + qsizetype lineEnd = log.indexOf(QLatin1Char('\n'), identityPos); + if (lineEnd < 0) lineEnd = log.size(); + const QString identityLine = log.mid(identityPos, lineEnd - identityPos); + static const QRegularExpression versionExpression{ + QStringLiteral(R"(\bfirmware=([0-9A-Za-z._+\-]+))"), + QRegularExpression::CaseInsensitiveOption}; + const auto match = versionExpression.match(identityLine); + const QString version = match.hasMatch() ? match.captured(1) : QString{}; + if (deviceFirmwareVersion_ != version) { + deviceFirmwareVersion_ = version; + emit stateChanged(); + } +} + +bool SmartSessionController::firmwareIsCurrent() const { + return device_ != nullptr && device_->protocolVersion() == QStringLiteral("1") && + !deviceFirmwareVersion_.isEmpty() && deviceFirmwareVersion_ == expectedFirmwareVersion(); +} + void SmartSessionController::reconcile() { if (device_ == nullptr || profiles_ == nullptr) { setPresentation( @@ -154,6 +356,25 @@ void SmartSessionController::reconcile() { return; } + if (updateRequested_) { + if (updateStage_ == UpdateStage::waitingForBootloader) { + setPresentation( + QStringLiteral("UPDATE NEEDS BOOT"), + updateStatus(), + false, + false); + return; + } + if (updateStage_ != UpdateStage::idle) { + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + updateStatus().isEmpty() ? QStringLiteral("Updating firmware…") : updateStatus(), + false, + false); + return; + } + } + if (device_->running()) { setPresentation( QStringLiteral("RUNNING"), @@ -183,15 +404,26 @@ void SmartSessionController::reconcile() { return; } - if (device_->protocolVersion() != QStringLiteral("1")) { + refreshFirmwareIdentity(); + if (!firmwareIsCurrent()) { + const QString versionText = deviceFirmwareVersion_.isEmpty() + ? QStringLiteral("legacy firmware") + : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); setPresentation( QStringLiteral("FIRMWARE UPDATE"), - QStringLiteral("The connected board needs compatible ARStack firmware before injection."), + QStringLiteral("%1 detected. ARStack Studio v%2 is ready to update it.") + .arg(versionText, expectedFirmwareVersion()), false, true); return; } + if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(true); + } + const QVariantMap profile = profiles_->selectedProfile(); const bool deployable = profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready"); @@ -226,8 +458,6 @@ void SmartSessionController::reconcile() { } if (!device_->profileArmed()) { - // An unexpected de-arm after a successful session-owned sync must be - // recovered by another controlled sync before START can unlock. needsProfileSync_ = true; profileSyncInFlight_ = false; setPresentation( From 85372dbec700f31cea7e9605c9a51df4d148ca44 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:32:39 +0700 Subject: [PATCH 069/272] feat(studio): add one-click firmware update UX --- apps/arstack_studio/qml/WorkflowBar.qml | 141 ++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 242beabb8..400c913fa 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -13,6 +13,7 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false + property bool updatePromptDeferred: false SmartSessionController { id: smartSession @@ -32,11 +33,106 @@ SurfacePanel { controller.profileDirty = false controller.applyAllSignals() } + function onStateChanged() { + if (smartSession.firmwareUpdateRequired && !ribbon.updatePromptDeferred && !updateDialog.opened) + Qt.callLater(updateDialog.open) + } + function onFirmwareUpdateFinished(success) { + if (success) { + ribbon.updatePromptDeferred = false + controller.showMessage("Firmware updated. ARStack Studio is preparing 4I+4V injection.", false) + } + } + } + + Connections { + target: device + function onDeviceVerifiedChanged() { + if (!device.deviceVerified) + ribbon.updatePromptDeferred = false + } + } + + Dialog { + id: updateDialog + modal: true + anchors.centerIn: Overlay.overlay + width: 430 + title: "Firmware update" + closePolicy: Popup.NoAutoClose + + background: Rectangle { + color: ribbon.theme.surface + radius: 10 + border.width: 1 + border.color: ribbon.theme.line + } + + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: smartSession.deviceFirmwareVersion.length + ? "ESP32-P4 is running ARStack firmware v" + smartSession.deviceFirmwareVersion + "." + : "ESP32-P4 is running legacy ARStack firmware." + color: ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 13 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: "Update to v" + smartSession.expectedFirmwareVersion + " now? The firmware is already included with ARStack Studio." + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: "SMV output will be stopped safely if required. Studio will flash, reset, reconnect, and prepare the default 4I+4V profile automatically." + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Later" + onClicked: { + ribbon.updatePromptDeferred = true + updateDialog.close() + } + } + CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Update now" + tone: "accent" + implicitWidth: 118 + onClicked: { + if (smartSession.beginFirmwareUpdate()) { + ribbon.updatePromptDeferred = false + updateDialog.close() + } else { + controller.showMessage("Firmware update could not start. Open Advanced for recovery details.", true) + } + } + } + } + } } readonly property color smartStateColor: { if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green - if (smartSession.state === "CONNECTING" || smartSession.state === "PREPARING 4I+4V" || smartSession.state === "FIRMWARE UPDATE") return theme.amber + if (smartSession.state === "CONNECTING" || smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || + smartSession.state === "UPDATE NEEDS BOOT") return theme.amber if (smartSession.state === "DEVICE FOUND") return theme.accent if (smartSession.state === "PROFILE BLOCKED" || smartSession.state === "SETUP ERROR") return theme.red return theme.muted @@ -45,6 +141,7 @@ SurfacePanel { function startReason() { if (device.running) return "SMV output is already running." if (smartSession.firmwareUpdateRequired) return "Firmware update is required before injection." + if (smartSession.updatingFirmware) return "Firmware update is in progress." if (smartSession.state === "CONNECTING") return "ARStack Studio is connecting automatically." if (smartSession.state === "PREPARING 4I+4V") return "Preparing the default 4I+4V profile automatically…" if (smartSession.state === "WAITING FOR DEVICE") return "Connect the ESP32-P4; ARStack Studio will detect it automatically." @@ -120,19 +217,21 @@ SurfacePanel { text: "Balanced" iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") toolTipText: "Apply balanced three-phase values" + enabled: !smartSession.updatingFirmware onClicked: ribbon.controller.balanced() } ActionButton { text: "Zero" iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") toolTipText: "Set every current and voltage magnitude to zero" + enabled: !smartSession.updatingFirmware onClicked: ribbon.controller.zeroAll() } Rectangle { width: 1; height: 28; color: ribbon.theme.lineSoft } ActionButton { - visible: !ribbon.compact + visible: !ribbon.compact && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Phasor" iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") tone: ribbon.controller.phasorDockVisible || ribbon.controller.phasorDetached ? "accent" : "neutral" @@ -142,7 +241,7 @@ SurfacePanel { } } ActionButton { - visible: !ribbon.compact + visible: !ribbon.compact && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Waveform" iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") tone: ribbon.controller.waveformDockVisible || ribbon.controller.waveformDetached ? "accent" : "neutral" @@ -154,16 +253,44 @@ SurfacePanel { Item { Layout.fillWidth: true } + ProgressBar { + visible: smartSession.updatingFirmware + indeterminate: true + Layout.preferredWidth: ribbon.compact ? 130 : 210 + Layout.alignment: Qt.AlignVCenter + } + Label { - visible: smartSession.state === "CONNECTING" || smartSession.state === "PREPARING 4I+4V" - text: smartSession.state === "CONNECTING" ? "Connecting…" : "Preparing…" + visible: smartSession.updateNeedsBootloaderHelp + text: "BOOT + RESET required" color: ribbon.theme.amber font.family: ribbon.uiFont font.pixelSize: 10 + font.weight: Font.DemiBold + } + ActionButton { + visible: smartSession.updateNeedsBootloaderHelp + text: "Retry update" + tone: "accent" + implicitWidth: 118 + toolTipText: smartSession.updateStatus + onClicked: smartSession.retryFirmwareUpdate() + } + + ActionButton { + visible: smartSession.firmwareUpdateRequired && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp + text: "Update firmware" + tone: "accent" + implicitWidth: 132 + onClicked: { + ribbon.updatePromptDeferred = false + updateDialog.open() + } } ActionButton { - visible: !device.running + visible: !device.running && !smartSession.firmwareUpdateRequired && + !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Start" iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") tone: "success" @@ -173,7 +300,7 @@ SurfacePanel { onClicked: device.start() } ActionButton { - visible: device.running + visible: device.running && !smartSession.updatingFirmware text: "Stop" iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") tone: "danger" From 453d20036fcee2e8b6ee40da09782f2c55c0a062 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:36:11 +0700 Subject: [PATCH 070/272] fix(studio): make smart firmware update state deterministic --- .../src/SmartSessionController.cpp | 98 +++++++++++-------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index cb0fee3c9..b9a7db051 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -34,7 +34,7 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent reconnectTimer_.setInterval(1800); reconnectTimer_.setSingleShot(true); connect(&reconnectTimer_, &QTimer::timeout, this, [this] { - if (device_ == nullptr || updateStage_ != UpdateStage::reconnecting) return; + if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return; static_cast(device_->autoDetectAndConnect()); reconcile(); }); @@ -55,17 +55,25 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent false); QTimer::singleShot(0, this, [this] { if (!firmware_->installFirmware(updatePort_)) { - updateStage_ = firmware_->bootloaderHelpNeeded() - ? UpdateStage::waitingForBootloader - : UpdateStage::idle; - if (updateStage_ == UpdateStage::idle) updateRequested_ = false; + if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } else { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); + } reconcile(); } }); return; } + if (firmware_->bootloaderHelpNeeded()) { updateStage_ = UpdateStage::waitingForBootloader; + } else { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); } } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy() && firmware_->bootloaderHelpNeeded()) { @@ -76,6 +84,7 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent connect(firmware_, &FirmwareManager::installationFinished, this, [this](const bool resetSucceeded) { if (!updateRequested_) return; + if (resetSucceeded) { updateStage_ = UpdateStage::reconnecting; setPresentation( @@ -84,13 +93,20 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent false, false); reconnectTimer_.start(); - } else if (firmware_->bootloaderHelpNeeded()) { + return; + } + + if (firmware_->bootloaderHelpNeeded()) { updateStage_ = UpdateStage::waitingForBootloader; reconcile(); - } else { - updateStage_ = UpdateStage::reconnecting; - reconnectTimer_.start(); + return; } + + // The flash itself succeeded but automatic reset did not. Give the board + // one reconnect window before surfacing a recovery failure. + updateStage_ = UpdateStage::reconnecting; + reconnectTimer_.start(); + reconcile(); }); } @@ -113,7 +129,6 @@ QString SmartSessionController::updateStatus() const { case UpdateStage::stopping: return QStringLiteral("Stopping SMV output safely…"); case UpdateStage::probing: - return firmware_->status(); case UpdateStage::flashing: return firmware_->status(); case UpdateStage::reconnecting: @@ -166,10 +181,10 @@ bool SmartSessionController::beginFirmwareUpdate() { !firmware_->bundleReady() || firmware_->busy()) { return false; } - const QString port = device_->portName().trimmed(); - if (port.isEmpty()) return false; - updatePort_ = port; + updatePort_ = device_->portName().trimmed(); + if (updatePort_.isEmpty()) return false; + updateRequested_ = true; needsProfileSync_ = true; profileSyncInFlight_ = false; @@ -197,8 +212,8 @@ bool SmartSessionController::beginFirmwareUpdate() { bool SmartSessionController::retryFirmwareUpdate() { if (firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; updateRequested_ = true; - if (device_ != nullptr && device_->connected()) device_->disconnectPort(); updateStage_ = UpdateStage::probing; + if (device_ != nullptr && device_->connected()) device_->disconnectPort(); setPresentation( QStringLiteral("UPDATING FIRMWARE"), QStringLiteral("Checking ESP32-P4 bootloader…"), @@ -219,8 +234,8 @@ void SmartSessionController::continueFirmwareUpdate() { return; } - if (device_->connected()) device_->disconnectPort(); updateStage_ = UpdateStage::probing; + if (device_->connected()) device_->disconnectPort(); setPresentation( QStringLiteral("UPDATING FIRMWARE"), QStringLiteral("Checking ESP32-P4 and preparing firmware…"), @@ -236,8 +251,8 @@ void SmartSessionController::reconnectDeviceSignals() { if (device_ == nullptr) return; connect(device_, &DeviceController::deviceVerifiedChanged, this, [this] { - refreshFirmwareIdentity(); if (device_ != nullptr && device_->deviceVerified()) { + refreshFirmwareIdentity(); needsProfileSync_ = true; profileSyncInFlight_ = false; prepareTimer_.start(); @@ -316,6 +331,7 @@ void SmartSessionController::refreshFirmwareIdentity() { const QString log = device_->logText(); const qsizetype identityPos = log.lastIndexOf(QStringLiteral("ARSTACK identity"), -1, Qt::CaseInsensitive); if (identityPos < 0) return; + qsizetype lineEnd = log.indexOf(QLatin1Char('\n'), identityPos); if (lineEnd < 0) lineEnd = log.size(); const QString identityLine = log.mid(identityPos, lineEnd - identityPos); @@ -323,11 +339,7 @@ void SmartSessionController::refreshFirmwareIdentity() { QStringLiteral(R"(\bfirmware=([0-9A-Za-z._+\-]+))"), QRegularExpression::CaseInsensitiveOption}; const auto match = versionExpression.match(identityLine); - const QString version = match.hasMatch() ? match.captured(1) : QString{}; - if (deviceFirmwareVersion_ != version) { - deviceFirmwareVersion_ = version; - emit stateChanged(); - } + deviceFirmwareVersion_ = match.hasMatch() ? match.captured(1) : QString{}; } bool SmartSessionController::firmwareIsCurrent() const { @@ -358,14 +370,12 @@ void SmartSessionController::reconcile() { if (updateRequested_) { if (updateStage_ == UpdateStage::waitingForBootloader) { - setPresentation( - QStringLiteral("UPDATE NEEDS BOOT"), - updateStatus(), - false, - false); + setPresentation(QStringLiteral("UPDATE NEEDS BOOT"), updateStatus(), false, false); return; } - if (updateStage_ != UpdateStage::idle) { + if (updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::probing || + updateStage_ == UpdateStage::flashing || + (updateStage_ == UpdateStage::reconnecting && !device_->deviceVerified())) { setPresentation( QStringLiteral("UPDATING FIRMWARE"), updateStatus().isEmpty() ? QStringLiteral("Updating firmware…") : updateStatus(), @@ -373,23 +383,12 @@ void SmartSessionController::reconcile() { false); return; } - } - - if (device_->running()) { - setPresentation( - QStringLiteral("RUNNING"), - QStringLiteral("4I + 4V · 4000 samples/s · live value apply"), - false, - false); - return; + // Reconnecting + verified intentionally falls through so the new + // semantic firmware identity is verified before declaring success. } if (device_->discovering() || (device_->connected() && !device_->deviceVerified())) { - setPresentation( - QStringLiteral("CONNECTING"), - device_->discoveryStatus(), - false, - false); + setPresentation(QStringLiteral("CONNECTING"), device_->discoveryStatus(), false, false); return; } @@ -406,6 +405,13 @@ void SmartSessionController::reconcile() { refreshFirmwareIdentity(); if (!firmwareIsCurrent()) { + if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + // Reconnected, but the semantic identity is still not the bundled + // release. Fail closed instead of looping or claiming success. + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); + } const QString versionText = deviceFirmwareVersion_.isEmpty() ? QStringLiteral("legacy firmware") : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); @@ -424,6 +430,15 @@ void SmartSessionController::reconcile() { emit firmwareUpdateFinished(true); } + if (device_->running()) { + setPresentation( + QStringLiteral("RUNNING"), + QStringLiteral("4I + 4V · 4000 samples/s · live value apply"), + false, + false); + return; + } + const QVariantMap profile = profiles_->selectedProfile(); const bool deployable = profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready"); @@ -465,6 +480,7 @@ void SmartSessionController::reconcile() { QStringLiteral("Restoring the default 4I+4V profile…"), false, false); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); return; } From f6b3dd36215a928ac879d659d46c993513b28fb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:36:38 +0700 Subject: [PATCH 071/272] docs(studio): sync smart UX progress after firmware intelligence --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 41 +++++++++++++++++----------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index 52acef121..7500c4594 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -2,32 +2,33 @@ Target operator experience: -`Plug ESP32-P4 -> Studio detects/connects -> current firmware/default 4I+4V prepared -> READY -> edit values -> START/STOP` +`Plug ESP32-P4 -> Studio detects/connects -> firmware checked -> default 4I+4V prepared -> READY -> edit values -> START/STOP` The normal operator must not need to understand SCL Class A, profile deployment, APPID/MAC/VLAN, firmware files, SHA-256, espflash, COM-port internals, or PTP unless an exceptional condition requires it. ## Status legend - ✅ implemented in branch -- 🟡 implemented but hardware/UX acceptance still required +- 🟡 implemented but CI/hardware/UX acceptance still required - ⬜ not implemented yet ## Smart workflow - ✅ Built-in 4I+4V / 4000 fps / 9-2LE reference profile loads automatically. -- ✅ Existing startup device discovery remains automatic. +- ✅ Startup device discovery is automatic. - ✅ Hot-plug/replug watchdog retries discovery while the device is genuinely offline. - ✅ After a compatible injector is verified, the default 4I+4V profile is synchronized/deployed automatically while output is stopped. +- ✅ Session-owned profile sync is required before READY; a stale `profileArmed` state from an earlier session is never trusted. - ✅ `Deploy`, `Check`, `Quick Start`, and normal Engineering tabs are removed from the primary operator workflow. - ✅ START remains an explicit operator action; the application never auto-starts SV output. - ✅ Normal workflow exposes one large START or STOP action, not both competing at once. - ✅ Phasor and waveform views start collapsed; operator can opt in when useful. -- ✅ Normal session orchestration now lives in native `SmartSessionController` instead of ribbon timing logic. -- 🟡 Automatic profile preparation must be hardware-tested across app launch, USB replug, STOP/START, and ESP power-cycle. +- ✅ Normal session orchestration lives in native `SmartSessionController` instead of ribbon timing logic. +- 🟡 Zero-configuration preparation must still be hardware-tested across app launch, USB replug, STOP/START, and ESP power-cycle. ## Live values / responsiveness -- ✅ Current and voltage edits already apply to the connected firmware live. +- ✅ Current and voltage edits apply to connected firmware live. - ✅ Magnitude and phase text edits are coalesced with a 90 ms debounce to avoid serial command storms while preserving immediate-feeling operation. - ⬜ Frequency editing still needs the same coalescing policy. - ⬜ Add bounded native command queue / last-value-wins protection so correctness does not depend on QML timing. @@ -37,12 +38,15 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Firmware image, manifest, SHA-256 contract, and `espflash` are bundled with the application package. - ✅ Recovery can probe and flash ESP32-P4 without ESP-IDF/Python on the operator PC. - ✅ ROM/download-mode recovery has guided BOOT/RESET fallback. -- 🟡 ESP-IDF project version is now pinned to 0.1.0 as the basis for semantic firmware identity. -- ⬜ Firmware `IDENTIFY` response still needs to expose semantic firmware version/capabilities. -- ⬜ DeviceController still needs to parse/store semantic firmware version. -- ⬜ Current-vs-outdated firmware comparison is not implemented yet. -- ⬜ Friendly `Firmware update available -> Update now?` notification is not implemented yet. -- ⬜ One-click update needs automatic transition to ROM flashing, visible progress, verify/reset/reconnect, and fallback only when automatic bootloader entry fails. +- ✅ ESP-IDF project version is pinned to `0.1.0` for deterministic semantic firmware identity. +- ✅ New firmware `IDENTIFY` appends `firmware=` and `capabilities=SMV-4I4V,LIVE-SETPOINTS` while preserving the older identity prefix for backwards compatibility. +- ✅ Smart session distinguishes current firmware from legacy/outdated firmware. Missing semantic version is treated as legacy rather than silently accepted. +- ✅ Friendly `Firmware update -> Update now?` operator prompt is implemented. +- 🟡 One-click update coordinator is implemented: safe STOP if required -> release serial -> ROM probe -> verified bundled flash -> reset -> reconnect -> verify semantic identity -> prepare 4I+4V. +- 🟡 If automatic ROM entry fails, normal UI falls back to BOOT + RESET guidance and `Retry update`. +- 🟡 Update UI currently uses an indeterminate progress bar. Actual numeric flash percentage is not parsed yet. +- ⬜ Hardware acceptance of legacy-firmware -> one-click update -> reconnect -> READY is still required. +- ⬜ Centralize the Firmware Manager instance used by Smart Session and Advanced configuration to avoid duplicate package validation objects. ## Operator UI simplification @@ -50,6 +54,7 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Low-level profile deployment is hidden from normal operation. - ✅ Advanced/recovery functionality remains available rather than being deleted. - ✅ Phasor/waveform visual noise is opt-in at startup rather than occupying the default workspace. +- ✅ Firmware internals remain hidden during the normal update path; only exceptional BOOT/RESET recovery is surfaced. - ⬜ Main workspace title and legacy header/footer language still need simplification. - ⬜ Remove duplicate device/status indicators and legacy keyboard-help noise from normal view. - ⬜ Advanced window needs final progressive-disclosure cleanup and larger minimum typography. @@ -61,8 +66,10 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Profile deployment remains stopped-only and fail-closed. - ✅ Firmware package/target/hash checks remain fail-closed. - ✅ Startup discovery race was removed; initial probe and hot-plug watchdog are serialized by state. -- ✅ Smart-session orchestration moved into one native C++ state machine (`WAITING FOR DEVICE / DEVICE FOUND / CONNECTING / FIRMWARE UPDATE / PREPARING 4I+4V / READY / RUNNING / ERROR`). -- ✅ Reconnect preparation is delayed briefly after identity verification so existing SHOW / PROFILE SHOW responses can settle before an automatic deploy. +- ✅ Smart-session orchestration moved into one native C++ state machine (`WAITING FOR DEVICE / DEVICE FOUND / CONNECTING / FIRMWARE UPDATE / UPDATING FIRMWARE / UPDATE NEEDS BOOT / PREPARING 4I+4V / READY / RUNNING / ERROR`). +- ✅ Reconnect preparation is delayed briefly after identity verification so existing SHOW / PROFILE SHOW responses can settle before automatic profile synchronization. +- ✅ Firmware update completion is not trusted until the newly reconnected firmware reports the expected semantic version. +- ✅ Firmware update state exits deterministically on probe rejection/failure instead of hanging indefinitely. - ⬜ Add bounded retry/backoff for disconnect/reconnect and serial errors. - ⬜ Add regression tests for USB removal during READY and RUNNING. - ⬜ Add regression tests for malformed/slow serial responses and command timeouts. @@ -70,6 +77,8 @@ The normal operator must not need to understand SCL Class A, profile deployment, ## Current checkpoint -Checkpoint A — **zero-configuration normal path + native smart-session orchestration** is implemented in the branch and awaiting CI + hardware acceptance. +Checkpoint B — **zero-configuration normal path + native smart-session orchestration + semantic firmware update flow** is implemented in the branch. -Next implementation checkpoint: **semantic firmware identity + automatic update UX**, followed by native command-queue hardening and final visual cleanup. +Current validation state: **CI and hardware acceptance pending for the newest Smart UX head.** Do not treat this checkpoint as release-ready until those gates pass. + +Next implementation checkpoint after CI: native command-queue hardening, frequency coalescing, final main-window visual cleanup, then hardware acceptance of both current-firmware and legacy-firmware paths. From 57c640d89863471dff786dc49e3e50d182df1c66 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:37:46 +0700 Subject: [PATCH 072/272] fix(studio): enforce semantic firmware identity in native safety gate --- .../src/StudioDeviceController.hpp | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 9ad9e5e48..a18c6ebb7 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -3,31 +3,51 @@ #include "DeviceController.hpp" +#include + +#ifndef ARSTACK_STUDIO_VERSION +#define ARSTACK_STUDIO_VERSION "0.1.0" +#endif + class StudioDeviceController : public DeviceController { Q_OBJECT public: explicit StudioDeviceController(QObject* parent = nullptr) : DeviceController(parent) {} + [[nodiscard]] bool currentFirmwareIdentitySeen() const { + const QString log = logText(); + const qsizetype identityPos = log.lastIndexOf(QStringLiteral("ARSTACK identity"), -1, Qt::CaseInsensitive); + if (identityPos < 0) return false; + qsizetype lineEnd = log.indexOf(QLatin1Char('\n'), identityPos); + if (lineEnd < 0) lineEnd = log.size(); + const QString identityLine = log.mid(identityPos, lineEnd - identityPos); + static const QRegularExpression versionExpression{ + QStringLiteral(R"(\bfirmware=([0-9A-Za-z._+\-]+))"), + QRegularExpression::CaseInsensitiveOption}; + const auto match = versionExpression.match(identityLine); + return match.hasMatch() && match.captured(1) == QStringLiteral(ARSTACK_STUDIO_VERSION); + } + Q_INVOKABLE bool start() { if (!deviceVerified()) { emit deviceMessage(QStringLiteral("Connect and verify the ARStack ESP32-P4 before Start.")); return false; } - if (protocolVersion() != QStringLiteral("1")) { - emit deviceMessage(QStringLiteral("Firmware protocol mismatch. Install the P0 firmware before Start.")); + if (protocolVersion() != QStringLiteral("1") || !currentFirmwareIdentitySeen()) { + emit deviceMessage(QStringLiteral("Firmware update required before Start.")); return false; } if (!profileArmed() || profileDeploying()) { - emit deviceMessage(QStringLiteral("Deploy and arm a validated 4I+4V profile before Start.")); + emit deviceMessage(QStringLiteral("ARStack Studio is still preparing the 4I+4V profile.")); return false; } return DeviceController::start(); } Q_INVOKABLE bool deployProfile(const QVariantMap& profile) { - if (protocolVersion() != QStringLiteral("1")) { - emit deviceMessage(QStringLiteral("Firmware protocol mismatch. Profile deployment is blocked.")); + if (protocolVersion() != QStringLiteral("1") || !currentFirmwareIdentitySeen()) { + emit deviceMessage(QStringLiteral("Firmware update required before profile synchronization.")); return false; } return DeviceController::deployProfile(profile); From 701f74075ad3b24300307827472d2548d4aa1389 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:38:16 +0700 Subject: [PATCH 073/272] docs(studio): record semantic firmware safety gate --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index 7500c4594..0adfce560 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -62,7 +62,8 @@ The normal operator must not need to understand SCL Class A, profile deployment, ## Robustness / crash resistance -- ✅ Native START policy still fails closed unless verified protocol/profile state is safe. +- ✅ Native START policy fails closed unless the device is verified, protocol is supported, semantic firmware identity matches the Studio build, and a profile is armed. +- ✅ Hidden/legacy Start and Deploy shortcuts cannot bypass a required firmware update merely because an older firmware still reports protocol v1. - ✅ Profile deployment remains stopped-only and fail-closed. - ✅ Firmware package/target/hash checks remain fail-closed. - ✅ Startup discovery race was removed; initial probe and hot-plug watchdog are serialized by state. @@ -77,7 +78,7 @@ The normal operator must not need to understand SCL Class A, profile deployment, ## Current checkpoint -Checkpoint B — **zero-configuration normal path + native smart-session orchestration + semantic firmware update flow** is implemented in the branch. +Checkpoint B — **zero-configuration normal path + native smart-session orchestration + semantic firmware update flow + native semantic safety gate** is implemented in the branch. Current validation state: **CI and hardware acceptance pending for the newest Smart UX head.** Do not treat this checkpoint as release-ready until those gates pass. From 48a1a33d22938d4a9c1289b01dcca0417fef29ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:49:14 +0700 Subject: [PATCH 074/272] refactor(studio): make live control hooks overridable --- apps/arstack_studio/src/DeviceController.hpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index c67b5fab4..0eae0a2bf 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -77,11 +77,11 @@ class DeviceController : public QObject { Q_INVOKABLE bool connectPort(const QString& portName); Q_INVOKABLE void disconnectPort(); Q_INVOKABLE bool sendShow(); - Q_INVOKABLE bool start(); + Q_INVOKABLE virtual bool start(); Q_INVOKABLE bool stop(); - Q_INVOKABLE bool zero(); - Q_INVOKABLE bool setFrequency(double hz); - Q_INVOKABLE bool setSignal( + Q_INVOKABLE virtual bool zero(); + Q_INVOKABLE virtual bool setFrequency(double hz); + Q_INVOKABLE virtual bool setSignal( const QString& signalId, double magnitude, double phaseDegrees, @@ -91,7 +91,7 @@ class DeviceController : public QObject { Q_INVOKABLE bool setEnabled(const QString& signalId, bool enabled); Q_INVOKABLE bool setQuality(const QString& signalId, quint32 quality); Q_INVOKABLE bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); - Q_INVOKABLE bool deployProfile(const QVariantMap& profile); + Q_INVOKABLE virtual bool deployProfile(const QVariantMap& profile); Q_INVOKABLE bool sendPtpShow(); Q_INVOKABLE bool startPtp(); Q_INVOKABLE bool stopPtp(); From 80cc6fd65fc99a8cc16cd358cb2ce0292cf40d97 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:49:39 +0700 Subject: [PATCH 075/272] feat(studio): coalesce live setpoint writes in native controller --- .../src/StudioDeviceController.hpp | 143 +++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index a18c6ebb7..40a5431b2 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -3,7 +3,12 @@ #include "DeviceController.hpp" +#include #include +#include + +#include +#include #ifndef ARSTACK_STUDIO_VERSION #define ARSTACK_STUDIO_VERSION "0.1.0" @@ -13,7 +18,19 @@ class StudioDeviceController : public DeviceController { Q_OBJECT public: - explicit StudioDeviceController(QObject* parent = nullptr) : DeviceController(parent) {} + explicit StudioDeviceController(QObject* parent = nullptr) : DeviceController(parent) { + liveFlushTimer_.setSingleShot(true); + liveFlushTimer_.setInterval(20); + connect(&liveFlushTimer_, &QTimer::timeout, this, [this] { + static_cast(flushLiveCommands()); + }); + connect(this, &DeviceController::connectedChanged, this, [this] { + if (!connected()) clearPendingLiveCommands(); + }); + connect(this, &DeviceController::deviceVerifiedChanged, this, [this] { + if (!deviceVerified()) clearPendingLiveCommands(); + }); + } [[nodiscard]] bool currentFirmwareIdentitySeen() const { const QString log = logText(); @@ -29,7 +46,7 @@ class StudioDeviceController : public DeviceController { return match.hasMatch() && match.captured(1) == QStringLiteral(ARSTACK_STUDIO_VERSION); } - Q_INVOKABLE bool start() { + Q_INVOKABLE bool start() override { if (!deviceVerified()) { emit deviceMessage(QStringLiteral("Connect and verify the ARStack ESP32-P4 before Start.")); return false; @@ -42,14 +59,134 @@ class StudioDeviceController : public DeviceController { emit deviceMessage(QStringLiteral("ARStack Studio is still preparing the 4I+4V profile.")); return false; } + if (!flushLiveCommands()) { + emit deviceMessage(QStringLiteral("Latest injection values could not be sent before Start.")); + return false; + } return DeviceController::start(); } - Q_INVOKABLE bool deployProfile(const QVariantMap& profile) { + Q_INVOKABLE bool zero() override { + pendingSignals_.clear(); + if (!frequencyPending_) liveFlushTimer_.stop(); + return DeviceController::zero(); + } + + Q_INVOKABLE bool setFrequency(const double hz) override { + if (!std::isfinite(hz) || hz < 0.0 || hz > 1000.0) { + emit deviceMessage(QStringLiteral("Frequency must be within 0..1000 Hz.")); + return false; + } + if (!deviceVerified()) return false; + + requestedFrequencyHz_ = hz; + pendingFrequencyHz_ = hz; + frequencyPending_ = true; + scheduleLiveFlush(); + return true; + } + + Q_INVOKABLE bool setSignal( + const QString& signalId, + const double magnitude, + const double phaseDegrees, + const quint32 quality, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) override { + const QString id = signalId.trimmed().toUpper(); + static const QStringList validIds{ + QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), + QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; + if (!deviceVerified() || !validIds.contains(id) || !std::isfinite(magnitude) || + !std::isfinite(phaseDegrees) || !std::isfinite(currentCountsPerAmp) || + !std::isfinite(voltageCountsPerVolt) || currentCountsPerAmp <= 0.0 || voltageCountsPerVolt <= 0.0 || + (requestedFrequencyHz_ > 0.0 && magnitude < 0.0)) { + return false; + } + + const double scale = id.startsWith(QLatin1Char('I')) ? currentCountsPerAmp : voltageCountsPerVolt; + const double counts = magnitude * scale; + const double phaseMdeg = phaseDegrees * 1000.0; + if (counts < static_cast(std::numeric_limits::min()) || + counts > static_cast(std::numeric_limits::max()) || + phaseMdeg < static_cast(std::numeric_limits::min()) || + phaseMdeg > static_cast(std::numeric_limits::max())) { + return false; + } + + pendingSignals_.insert(id, PendingSignal{ + magnitude, + phaseDegrees, + quality, + currentCountsPerAmp, + voltageCountsPerVolt}); + scheduleLiveFlush(); + return true; + } + + Q_INVOKABLE bool deployProfile(const QVariantMap& profile) override { if (protocolVersion() != QStringLiteral("1") || !currentFirmwareIdentitySeen()) { emit deviceMessage(QStringLiteral("Firmware update required before profile synchronization.")); return false; } return DeviceController::deployProfile(profile); } + +private: + struct PendingSignal { + double magnitude{}; + double phaseDegrees{}; + quint32 quality{}; + double currentCountsPerAmp{}; + double voltageCountsPerVolt{}; + }; + + void scheduleLiveFlush() { + if (!liveFlushTimer_.isActive()) liveFlushTimer_.start(); + } + + void clearPendingLiveCommands() { + liveFlushTimer_.stop(); + pendingSignals_.clear(); + frequencyPending_ = false; + } + + bool flushLiveCommands() { + liveFlushTimer_.stop(); + if (!deviceVerified()) { + clearPendingLiveCommands(); + return false; + } + + bool ok = true; + if (frequencyPending_) { + const double frequency = pendingFrequencyHz_; + frequencyPending_ = false; + ok = DeviceController::setFrequency(frequency) && ok; + } + + static const QStringList order{ + QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), + QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; + for (const QString& id : order) { + const auto it = pendingSignals_.find(id); + if (it == pendingSignals_.end()) continue; + const PendingSignal value = it.value(); + pendingSignals_.erase(it); + ok = DeviceController::setSignal( + id, + value.magnitude, + value.phaseDegrees, + value.quality, + value.currentCountsPerAmp, + value.voltageCountsPerVolt) && ok; + } + return ok; + } + + QTimer liveFlushTimer_; + QHash pendingSignals_; + double requestedFrequencyHz_{50.0}; + double pendingFrequencyHz_{50.0}; + bool frequencyPending_{false}; }; From 31eed82902a13f29809a0fbe9ce4fdcfd9e4199e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:50:19 +0700 Subject: [PATCH 076/272] docs(studio): advance Smart UX progress checkpoint --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index 0adfce560..ceac39ac0 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -29,9 +29,12 @@ The normal operator must not need to understand SCL Class A, profile deployment, ## Live values / responsiveness - ✅ Current and voltage edits apply to connected firmware live. -- ✅ Magnitude and phase text edits are coalesced with a 90 ms debounce to avoid serial command storms while preserving immediate-feeling operation. -- ⬜ Frequency editing still needs the same coalescing policy. -- ⬜ Add bounded native command queue / last-value-wins protection so correctness does not depend on QML timing. +- ✅ Magnitude and phase text edits are coalesced with a 90 ms UI debounce to avoid serial command storms while preserving immediate-feeling operation. +- ✅ Frequency and signal writes now pass through a bounded native last-value-wins queue in `StudioDeviceController`. +- ✅ Native queue capacity is bounded by design to one pending frequency plus the eight fixed 4I+4V channel IDs; it cannot grow with repeated typing. +- ✅ Native live writes flush on a short timer, and START forces the newest queued values to the device before enabling output. +- ✅ Disconnect/unverify clears pending live writes; ZERO cancels pending channel writes so an old queued value cannot reappear after zeroing. +- 🟡 Native live-write coalescing still needs hardware feel/latency acceptance during rapid edits. ## Firmware intelligence @@ -71,6 +74,7 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Reconnect preparation is delayed briefly after identity verification so existing SHOW / PROFILE SHOW responses can settle before automatic profile synchronization. - ✅ Firmware update completion is not trusted until the newly reconnected firmware reports the expected semantic version. - ✅ Firmware update state exits deterministically on probe rejection/failure instead of hanging indefinitely. +- ✅ Live-edit write backlog is bounded and last-value-wins instead of growing with operator keystrokes. - ⬜ Add bounded retry/backoff for disconnect/reconnect and serial errors. - ⬜ Add regression tests for USB removal during READY and RUNNING. - ⬜ Add regression tests for malformed/slow serial responses and command timeouts. @@ -78,8 +82,8 @@ The normal operator must not need to understand SCL Class A, profile deployment, ## Current checkpoint -Checkpoint B — **zero-configuration normal path + native smart-session orchestration + semantic firmware update flow + native semantic safety gate** is implemented in the branch. +Checkpoint C — **zero-configuration workflow + semantic firmware update + bounded native live-write coalescing** is implemented in the branch. Current validation state: **CI and hardware acceptance pending for the newest Smart UX head.** Do not treat this checkpoint as release-ready until those gates pass. -Next implementation checkpoint after CI: native command-queue hardening, frequency coalescing, final main-window visual cleanup, then hardware acceptance of both current-firmware and legacy-firmware paths. +Next implementation checkpoint after CI: final main-window visual cleanup, centralize firmware-manager ownership, bounded reconnect/backoff, then hardware acceptance of both current-firmware and legacy-firmware paths. From f781521bdf6092b76a441903ca941aed25642b34 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:51:36 +0700 Subject: [PATCH 077/272] refactor(studio): inject shared firmware service into smart session --- apps/arstack_studio/src/SmartSessionController.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index dbe16b582..2e3a1c78c 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -12,6 +12,7 @@ class SmartSessionController : public QObject { Q_OBJECT Q_PROPERTY(QObject* device READ device WRITE setDevice NOTIFY dependenciesChanged) Q_PROPERTY(QObject* profiles READ profiles WRITE setProfiles NOTIFY dependenciesChanged) + Q_PROPERTY(QObject* firmware READ firmware WRITE setFirmware NOTIFY dependenciesChanged) Q_PROPERTY(QString state READ state NOTIFY stateChanged) Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) @@ -27,6 +28,7 @@ class SmartSessionController : public QObject { [[nodiscard]] QObject* device() const noexcept; [[nodiscard]] QObject* profiles() const noexcept; + [[nodiscard]] QObject* firmware() const noexcept; [[nodiscard]] QString state() const; [[nodiscard]] QString statusText() const; [[nodiscard]] bool startReady() const noexcept; @@ -39,6 +41,7 @@ class SmartSessionController : public QObject { void setDevice(QObject* object); void setProfiles(QObject* object); + void setFirmware(QObject* object); Q_INVOKABLE void start(); Q_INVOKABLE void reconcile(); @@ -56,6 +59,7 @@ class SmartSessionController : public QObject { void reconnectDeviceSignals(); void reconnectProfileSignals(); + void reconnectFirmwareSignals(); bool ensureDefaultProfile(); void setPresentation(QString state, QString status, bool startReady, bool firmwareUpdateRequired); void refreshFirmwareIdentity(); From 7a842d1d489bda775d1e861994cce74d1a9ceec3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:52:23 +0700 Subject: [PATCH 078/272] refactor(studio): share one firmware service across smart and advanced flows --- .../src/SmartSessionController.cpp | 161 +++++++++--------- 1 file changed, 84 insertions(+), 77 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index b9a7db051..4a8b1d370 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -16,8 +16,6 @@ #endif SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { - firmware_ = new FirmwareManager(this); - discoveryTimer_.setInterval(2500); discoveryTimer_.setSingleShot(false); connect(&discoveryTimer_, &QTimer::timeout, this, [this] { @@ -38,80 +36,11 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent static_cast(device_->autoDetectAndConnect()); reconcile(); }); - - connect(firmware_, &FirmwareManager::stateChanged, this, [this] { - if (!updateRequested_) { - reconcile(); - return; - } - - if (updateStage_ == UpdateStage::probing && !firmware_->busy()) { - if (firmware_->targetVerified()) { - updateStage_ = UpdateStage::flashing; - setPresentation( - QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Installing verified ARStack firmware…"), - false, - false); - QTimer::singleShot(0, this, [this] { - if (!firmware_->installFirmware(updatePort_)) { - if (firmware_->bootloaderHelpNeeded()) { - updateStage_ = UpdateStage::waitingForBootloader; - } else { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; - emit firmwareUpdateFinished(false); - } - reconcile(); - } - }); - return; - } - - if (firmware_->bootloaderHelpNeeded()) { - updateStage_ = UpdateStage::waitingForBootloader; - } else { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; - emit firmwareUpdateFinished(false); - } - } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy() && - firmware_->bootloaderHelpNeeded()) { - updateStage_ = UpdateStage::waitingForBootloader; - } - reconcile(); - }); - - connect(firmware_, &FirmwareManager::installationFinished, this, [this](const bool resetSucceeded) { - if (!updateRequested_) return; - - if (resetSucceeded) { - updateStage_ = UpdateStage::reconnecting; - setPresentation( - QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Firmware installed. Reconnecting to ESP32-P4…"), - false, - false); - reconnectTimer_.start(); - return; - } - - if (firmware_->bootloaderHelpNeeded()) { - updateStage_ = UpdateStage::waitingForBootloader; - reconcile(); - return; - } - - // The flash itself succeeded but automatic reset did not. Give the board - // one reconnect window before surfacing a recovery failure. - updateStage_ = UpdateStage::reconnecting; - reconnectTimer_.start(); - reconcile(); - }); } QObject* SmartSessionController::device() const noexcept { return device_; } QObject* SmartSessionController::profiles() const noexcept { return profiles_; } +QObject* SmartSessionController::firmware() const noexcept { return firmware_; } QString SmartSessionController::state() const { return state_; } QString SmartSessionController::statusText() const { return statusText_; } bool SmartSessionController::startReady() const noexcept { return startReady_; } @@ -168,6 +97,16 @@ void SmartSessionController::setProfiles(QObject* object) { reconcile(); } +void SmartSessionController::setFirmware(QObject* object) { + auto* next = qobject_cast(object); + if (firmware_ == next) return; + if (firmware_ != nullptr) disconnect(firmware_, nullptr, this, nullptr); + firmware_ = next; + reconnectFirmwareSignals(); + emit dependenciesChanged(); + reconcile(); +} + void SmartSessionController::start() { if (started_) return; started_ = true; @@ -301,6 +240,78 @@ void SmartSessionController::reconnectProfileSignals() { }); } +void SmartSessionController::reconnectFirmwareSignals() { + if (firmware_ == nullptr) return; + + connect(firmware_, &FirmwareManager::stateChanged, this, [this] { + if (!updateRequested_) { + reconcile(); + return; + } + + if (updateStage_ == UpdateStage::probing && !firmware_->busy()) { + if (firmware_->targetVerified()) { + updateStage_ = UpdateStage::flashing; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Installing verified ARStack firmware…"), + false, + false); + QTimer::singleShot(0, this, [this] { + if (!firmware_->installFirmware(updatePort_)) { + if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } else { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); + } + reconcile(); + } + }); + return; + } + + if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } else { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); + } + } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy() && + firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } + reconcile(); + }); + + connect(firmware_, &FirmwareManager::installationFinished, this, [this](const bool resetSucceeded) { + if (!updateRequested_) return; + + if (resetSucceeded) { + updateStage_ = UpdateStage::reconnecting; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Firmware installed. Reconnecting to ESP32-P4…"), + false, + false); + reconnectTimer_.start(); + return; + } + + if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + reconcile(); + return; + } + + updateStage_ = UpdateStage::reconnecting; + reconnectTimer_.start(); + reconcile(); + }); +} + bool SmartSessionController::ensureDefaultProfile() { if (profiles_ == nullptr) return false; if (profiles_->hasProfiles()) return true; @@ -348,7 +359,7 @@ bool SmartSessionController::firmwareIsCurrent() const { } void SmartSessionController::reconcile() { - if (device_ == nullptr || profiles_ == nullptr) { + if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) { setPresentation( QStringLiteral("INITIALIZING"), QStringLiteral("Preparing ARStack Studio…"), @@ -383,8 +394,6 @@ void SmartSessionController::reconcile() { false); return; } - // Reconnecting + verified intentionally falls through so the new - // semantic firmware identity is verified before declaring success. } if (device_->discovering() || (device_->connected() && !device_->deviceVerified())) { @@ -406,8 +415,6 @@ void SmartSessionController::reconcile() { refreshFirmwareIdentity(); if (!firmwareIsCurrent()) { if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { - // Reconnected, but the semantic identity is still not the bundled - // release. Fail closed instead of looping or claiming success. updateRequested_ = false; updateStage_ = UpdateStage::idle; emit firmwareUpdateFinished(false); From bc7a43f6dc7e5865370226abaea78012908f72c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:52:45 +0700 Subject: [PATCH 079/272] refactor(studio): register one shared firmware service --- apps/arstack_studio/src/main.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 0c1d8e4d7..cc744f4de 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -133,10 +133,12 @@ int main(int argc, char* argv[]) { QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); + FirmwareManager firmwareService; + qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); - qmlRegisterType("ARStack.Studio", 1, 0, "FirmwareManager"); qmlRegisterType("ARStack.Studio", 1, 0, "SmartSessionController"); + qmlRegisterSingletonInstance("ARStack.Studio", 1, 0, "FirmwareService", &firmwareService); QQmlApplicationEngine engine; QObject::connect( From de3c47cf0ae0b367a50212d389190b53f8adad4e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:53:14 +0700 Subject: [PATCH 080/272] refactor(studio): use shared firmware service in smart workflow --- apps/arstack_studio/qml/WorkflowBar.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 400c913fa..deab668c8 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -19,6 +19,7 @@ SurfacePanel { id: smartSession device: ribbon.device profiles: ribbon.profiles + firmware: FirmwareService } Component.onCompleted: { @@ -200,7 +201,7 @@ SurfacePanel { CalmButton { theme: ribbon.theme uiFont: ribbon.uiFont - text: "Advanced…" + text: "Advanced" implicitHeight: 28 font.pixelSize: 9 toolTipText: "Firmware, SCL, waveform stress, PTP and diagnostics" From b161ce64301a602d4699b72ae3ac9d1da102e75a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:53:32 +0700 Subject: [PATCH 081/272] refactor(studio): reuse shared firmware service in Advanced --- apps/arstack_studio/qml/ConfigurationHub.qml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 300a1d06e..d0fef5b3d 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -13,14 +13,10 @@ Item { property string uiFont: "Inter" property string monoFont: "Inter" - FirmwareManager { id: firmwareManager } - function openEngineeringFile() { profileInspector.openEngineeringFile() } function showRecovery() { expertTabs.currentIndex = 1 } Component.onCompleted: { - // A blank/old-firmware board cannot identify as an ARStack injector yet. - // Make recovery the first screen operators see while offline. if (!hub.device.deviceVerified) expertTabs.currentIndex = 1 } @@ -67,15 +63,15 @@ Item { Layout.fillWidth: true spacing: 1 Label { - text: "SETUP & RECOVERY" + text: "ADVANCED" color: hub.theme.muted font.family: hub.uiFont - font.pixelSize: 8 + font.pixelSize: 9 font.weight: Font.Bold font.letterSpacing: 0.9 } Label { - text: "Device, firmware & engineering" + text: "Device & engineering settings" color: hub.theme.text font.family: hub.uiFont font.pixelSize: 16 @@ -88,7 +84,7 @@ Item { : "Recovery available" color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber font.family: hub.uiFont - font.pixelSize: 9 + font.pixelSize: 10 font.weight: Font.DemiBold } } @@ -123,7 +119,7 @@ Item { FirmwarePanel { theme: hub.theme device: hub.device - firmware: firmwareManager + firmware: FirmwareService uiFont: hub.uiFont monoFont: hub.monoFont } From 3fec2a1155a62a3a23097276a2b6c4659fb51640 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:54:22 +0700 Subject: [PATCH 082/272] fix(studio): bound post-flash reconnect attempts --- apps/arstack_studio/src/SmartSessionController.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 2e3a1c78c..6fd9b5d29 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -83,5 +83,6 @@ class SmartSessionController : public QObject { bool needsProfileSync_{true}; bool profileSyncInFlight_{false}; bool updateRequested_{false}; + int updateReconnectAttempts_{0}; UpdateStage updateStage_{UpdateStage::idle}; }; From d611dcab9b208f4e1abc1ead2fb7075faab82efe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:55:16 +0700 Subject: [PATCH 083/272] fix(studio): make firmware reconnect bounded and deterministic --- .../src/SmartSessionController.cpp | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 4a8b1d370..1f3180bc7 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -9,12 +9,17 @@ #include #include +#include #include #ifndef ARSTACK_STUDIO_VERSION #define ARSTACK_STUDIO_VERSION "0.1.0" #endif +namespace { +constexpr int kMaxUpdateReconnectAttempts = 6; +} + SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { discoveryTimer_.setInterval(2500); discoveryTimer_.setSingleShot(false); @@ -29,11 +34,28 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent prepareTimer_.setSingleShot(true); connect(&prepareTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); - reconnectTimer_.setInterval(1800); + reconnectTimer_.setInterval(3000); reconnectTimer_.setSingleShot(true); connect(&reconnectTimer_, &QTimer::timeout, this, [this] { if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return; + if (device_->deviceVerified()) { + reconcile(); + return; + } + if (device_->discovering() || device_->connected()) { + reconnectTimer_.start(); + return; + } + if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); + reconcile(); + return; + } + ++updateReconnectAttempts_; static_cast(device_->autoDetectAndConnect()); + reconnectTimer_.start(); reconcile(); }); } @@ -61,7 +83,9 @@ QString SmartSessionController::updateStatus() const { case UpdateStage::flashing: return firmware_->status(); case UpdateStage::reconnecting: - return QStringLiteral("Firmware installed. Waiting for ESP32-P4 to reconnect…"); + return QStringLiteral("Firmware installed. Reconnecting to ESP32-P4… %1/%2") + .arg(std::min(updateReconnectAttempts_ + 1, kMaxUpdateReconnectAttempts)) + .arg(kMaxUpdateReconnectAttempts); case UpdateStage::waitingForBootloader: return QStringLiteral("Hold BOOT, press and release RESET, release BOOT, then Retry update."); case UpdateStage::idle: @@ -125,6 +149,7 @@ bool SmartSessionController::beginFirmwareUpdate() { if (updatePort_.isEmpty()) return false; updateRequested_ = true; + updateReconnectAttempts_ = 0; needsProfileSync_ = true; profileSyncInFlight_ = false; @@ -151,6 +176,7 @@ bool SmartSessionController::beginFirmwareUpdate() { bool SmartSessionController::retryFirmwareUpdate() { if (firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; updateRequested_ = true; + updateReconnectAttempts_ = 0; updateStage_ = UpdateStage::probing; if (device_ != nullptr && device_->connected()) device_->disconnectPort(); setPresentation( @@ -289,6 +315,7 @@ void SmartSessionController::reconnectFirmwareSignals() { connect(firmware_, &FirmwareManager::installationFinished, this, [this](const bool resetSucceeded) { if (!updateRequested_) return; + updateReconnectAttempts_ = 0; if (resetSucceeded) { updateStage_ = UpdateStage::reconnecting; setPresentation( From 59672faa0d9b304ca04295a6096ba99549ac7914 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:56:38 +0700 Subject: [PATCH 084/272] feat(studio): expose firmware flash progress --- apps/arstack_studio/src/FirmwareManager.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index a0c5c70b4..925e3fa8a 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -12,6 +12,7 @@ class FirmwareManager : public QObject { Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) Q_PROPERTY(bool targetVerified READ targetVerified NOTIFY stateChanged) Q_PROPERTY(bool bootloaderHelpNeeded READ bootloaderHelpNeeded NOTIFY stateChanged) + Q_PROPERTY(int flashProgress READ flashProgress NOTIFY stateChanged) Q_PROPERTY(QString selectedPort READ selectedPort NOTIFY stateChanged) Q_PROPERTY(QString targetChip READ targetChip NOTIFY stateChanged) Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged) @@ -29,6 +30,7 @@ class FirmwareManager : public QObject { [[nodiscard]] bool busy() const noexcept { return busy_; } [[nodiscard]] bool targetVerified() const noexcept { return targetVerified_; } [[nodiscard]] bool bootloaderHelpNeeded() const noexcept { return bootloaderHelpNeeded_; } + [[nodiscard]] int flashProgress() const noexcept { return flashProgress_; } [[nodiscard]] QString selectedPort() const { return selectedPort_; } [[nodiscard]] QString targetChip() const { return targetChip_; } [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; } @@ -62,6 +64,7 @@ class FirmwareManager : public QObject { bool loadManifest(); bool startEspflash(const QStringList& arguments, Operation operation); void finishOperation(int exitCode, QProcess::ExitStatus exitStatus); + void updateProgressFromOutput(const QString& text); void appendLog(const QString& text); void setStatus(const QString& text); void fail(const QString& text); @@ -79,6 +82,7 @@ class FirmwareManager : public QObject { QString status_{QStringLiteral("Firmware setup ready")}; QString bundleStatus_{QStringLiteral("Checking firmware package...")}; QString logText_; + int flashProgress_{-1}; bool bundleReady_{false}; bool flasherAvailable_{false}; bool busy_{false}; From f50665e947a696f11bea7b92fdf01b812e1df1d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:57:22 +0700 Subject: [PATCH 085/272] feat(studio): parse espflash percentage with safe fallback --- apps/arstack_studio/src/FirmwareManager.cpp | 35 ++++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 4fcb4860e..c58f768d8 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -13,6 +13,8 @@ #include #include +#include + namespace { constexpr auto kManifestName = "firmware-manifest.json"; constexpr auto kManifestSchema = "arstack.studio.firmware.v1"; @@ -31,11 +33,13 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { connect(&process_, &QProcess::readyReadStandardOutput, this, [this] { const QString text = QString::fromUtf8(process_.readAllStandardOutput()); operationOutput_ += text; + updateProgressFromOutput(text); appendLog(text); }); connect(&process_, &QProcess::readyReadStandardError, this, [this] { const QString text = QString::fromUtf8(process_.readAllStandardError()); operationOutput_ += text; + updateProgressFromOutput(text); appendLog(text); }); connect(&process_, qOverload(&QProcess::finished), @@ -44,9 +48,6 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { } bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, int& minor) { - // espflash 4.x prints Chip::Display as "esp32p4". Espressif/esptool and - // older logs commonly use "ESP32-P4". Accept both spellings, but still - // require an explicit revision before the P0 flash gate can open. static const QRegularExpression expression{ QStringLiteral(R"(Chip\s+type:\s*ESP32[-_ ]?P4\s*\(revision\s+v(\d+)\.(\d+)\))"), QRegularExpression::CaseInsensitiveOption}; @@ -85,6 +86,7 @@ void FirmwareManager::refreshBundle() { revisionPolicy_.clear(); firmwareSha256_.clear(); firmwareImagePath_.clear(); + flashProgress_ = -1; const QFileInfo flasherInfo{flasherPath()}; flasherAvailable_ = flasherInfo.exists() && flasherInfo.isFile() && flasherInfo.isExecutable(); @@ -180,6 +182,7 @@ bool FirmwareManager::probeTarget(const QString& portName) { selectedPort_ = port; targetVerified_ = false; bootloaderHelpNeeded_ = false; + flashProgress_ = -1; targetChip_ = QStringLiteral("Checking %1...").arg(port); busy_ = true; setStatus(QStringLiteral("Checking ESP32-P4 ROM identity on %1...").arg(port)); @@ -203,6 +206,7 @@ bool FirmwareManager::installFirmware(const QString& portName) { } bootloaderHelpNeeded_ = false; + flashProgress_ = 0; busy_ = true; setStatus(QStringLiteral("Installing ARStack firmware v%1 on %2...").arg(firmwareVersion_, port)); emit stateChanged(); @@ -221,6 +225,7 @@ void FirmwareManager::cancel() { process_.waitForFinished(1200); operation_ = Operation::none; busy_ = false; + flashProgress_ = -1; setStatus(QStringLiteral("Firmware operation cancelled.")); emit stateChanged(); } @@ -241,8 +246,6 @@ bool FirmwareManager::startEspflash(const QStringList& arguments, const Operatio operationOutput_.clear(); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert(QStringLiteral("ESPFLASH_PORT"), selectedPort_); - // espflash 4.x parses this environment setting as a strict boolean. - // "1" is rejected; use the documented textual boolean instead. environment.insert(QStringLiteral("ESPFLASH_SKIP_UPDATE_CHECK"), QStringLiteral("true")); process_.setProcessEnvironment(environment); process_.setProgram(flasherPath()); @@ -254,6 +257,7 @@ bool FirmwareManager::startEspflash(const QStringList& arguments, const Operatio fail(QStringLiteral("Unable to start bundled espflash.")); operation_ = Operation::none; busy_ = false; + flashProgress_ = -1; emit stateChanged(); return false; } @@ -305,11 +309,16 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt if (completed == Operation::flash) { if (!success) { busy_ = false; + flashProgress_ = -1; bootloaderHelpNeeded_ = true; fail(QStringLiteral("Firmware flash failed. Keep the board in Download mode and retry. Device contents were not trusted.")); emit stateChanged(); return; } + if (flashProgress_ != 100) { + flashProgress_ = 100; + emit stateChanged(); + } bootloaderHelpNeeded_ = false; setStatus(QStringLiteral("Flash completed. Resetting ESP32-P4...")); emit stateChanged(); @@ -338,6 +347,22 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt } } +void FirmwareManager::updateProgressFromOutput(const QString& text) { + if (operation_ != Operation::flash || text.isEmpty()) return; + static const QRegularExpression expression{QStringLiteral(R"((\d{1,3})\s*%)")}; + auto matches = expression.globalMatch(text); + int latest = -1; + while (matches.hasNext()) { + const int value = matches.next().captured(1).toInt(); + if (value >= 0 && value <= 100) latest = value; + } + if (latest < 0) return; + latest = std::clamp(latest, 0, 100); + if (flashProgress_ == latest) return; + flashProgress_ = latest; + emit stateChanged(); +} + void FirmwareManager::appendLog(const QString& text) { if (text.isEmpty()) return; const QString stamped = QStringLiteral("[%1] %2") From cef81883b3f68411ed01067e7ffa0a355e897cb0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:58:12 +0700 Subject: [PATCH 086/272] feat(studio): show real firmware flash progress --- apps/arstack_studio/qml/WorkflowBar.qml | 27 +++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index deab668c8..4d5cbbbc5 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -42,6 +42,8 @@ SurfacePanel { if (success) { ribbon.updatePromptDeferred = false controller.showMessage("Firmware updated. ARStack Studio is preparing 4I+4V injection.", false) + } else { + controller.showMessage("Firmware update did not complete. Open Advanced only if recovery help is needed.", true) } } } @@ -254,11 +256,28 @@ SurfacePanel { Item { Layout.fillWidth: true } - ProgressBar { + RowLayout { visible: smartSession.updatingFirmware - indeterminate: true - Layout.preferredWidth: ribbon.compact ? 130 : 210 - Layout.alignment: Qt.AlignVCenter + spacing: 8 + ProgressBar { + id: firmwareProgress + indeterminate: FirmwareService.flashProgress < 0 + from: 0 + to: 100 + value: FirmwareService.flashProgress < 0 ? 0 : FirmwareService.flashProgress + Layout.preferredWidth: ribbon.compact ? 125 : 190 + Layout.alignment: Qt.AlignVCenter + } + Label { + visible: FirmwareService.flashProgress >= 0 + text: FirmwareService.flashProgress + "%" + color: ribbon.theme.textSoft + font.family: ribbon.monoFont + font.pixelSize: 10 + font.weight: Font.DemiBold + Layout.preferredWidth: 36 + horizontalAlignment: Text.AlignRight + } } Label { From 72b92b49442d8de098304c752c2fb04c75859560 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:58:39 +0700 Subject: [PATCH 087/272] test(studio): expose flash progress parser contract --- apps/arstack_studio/src/FirmwareManager.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 925e3fa8a..8d97d38ea 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -44,6 +44,10 @@ class FirmwareManager : public QObject { // Espressif tooling prints "ESP32-P4". Keep one parser for both forms and // regression-test it from the packaged application contract. [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); + // Returns the newest valid 0..100 percentage in a chunk, or -1 when the + // flasher output carries no usable progress token. Cosmetic parsing must + // never decide whether a flash succeeds or fails. + [[nodiscard]] static int parseFlashProgress(const QString& output); Q_INVOKABLE void refreshBundle(); Q_INVOKABLE bool probeTarget(const QString& portName); From dd4ed2483cc01aede26c4cc7766106ec399d2e41 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:59:22 +0700 Subject: [PATCH 088/272] test(studio): make espflash progress parser deterministic --- apps/arstack_studio/src/FirmwareManager.cpp | 25 ++++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index c58f768d8..dba611708 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -62,6 +62,17 @@ bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, in return true; } +int FirmwareManager::parseFlashProgress(const QString& output) { + static const QRegularExpression expression{QStringLiteral(R"((\d{1,3})\s*%)")}; + auto matches = expression.globalMatch(output); + int latest = -1; + while (matches.hasNext()) { + const int value = matches.next().captured(1).toInt(); + if (value >= 0 && value <= 100) latest = value; + } + return latest; +} + QString FirmwareManager::bundleRoot() const { const QString overridePath = qEnvironmentVariable("ARSTACK_STUDIO_FIRMWARE_DIR").trimmed(); if (!overridePath.isEmpty()) return QDir::cleanPath(overridePath); @@ -349,17 +360,9 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt void FirmwareManager::updateProgressFromOutput(const QString& text) { if (operation_ != Operation::flash || text.isEmpty()) return; - static const QRegularExpression expression{QStringLiteral(R"((\d{1,3})\s*%)")}; - auto matches = expression.globalMatch(text); - int latest = -1; - while (matches.hasNext()) { - const int value = matches.next().captured(1).toInt(); - if (value >= 0 && value <= 100) latest = value; - } - if (latest < 0) return; - latest = std::clamp(latest, 0, 100); - if (flashProgress_ == latest) return; - flashProgress_ = latest; + const int latest = parseFlashProgress(text); + if (latest < 0 || flashProgress_ == latest) return; + flashProgress_ = std::clamp(latest, 0, 100); emit stateChanged(); } From 19dba5635e2982dfa735f600fd7dbe6c1f44197e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 16:59:46 +0700 Subject: [PATCH 089/272] test(studio): lock firmware progress parsing in package contract --- apps/arstack_studio/src/main.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index cc744f4de..1fdaed57e 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -81,22 +81,28 @@ int checkFirmwareContract(int argc, char* argv[]) { wrongMajor, wrongMinor); + const bool flashProgressFormat = + FirmwareManager::parseFlashProgress(QStringLiteral("Writing 7%\rWriting 64%\r")) == 64 && + FirmwareManager::parseFlashProgress(QStringLiteral("no progress token")) == -1 && + FirmwareManager::parseFlashProgress(QStringLiteral("Writing 104%")) == -1; + const bool valid = firmware.bundleReady() && firmware.flasherAvailable() && firmware.firmwareVersion() == QStringLiteral(ARSTACK_STUDIO_VERSION) && firmware.expectedProtocol() == QStringLiteral("1") && firmware.firmwareSha256().size() == 64 && - realEspflashFormat && dashedFormat && rejectsWrongChip; + realEspflashFormat && dashedFormat && rejectsWrongChip && flashProgressFormat; if (!valid) { qCritical().noquote() << "Firmware bundle/probe contract: FAIL ·" << firmware.bundleStatus() << "espflash-format=" << realEspflashFormat << "dashed-format=" << dashedFormat - << "wrong-chip-rejected=" << rejectsWrongChip; + << "wrong-chip-rejected=" << rejectsWrongChip + << "progress-format=" << flashProgressFormat; return 4; } qInfo().noquote() - << "Firmware bundle/probe contract: PASS · real espflash P4 revision format accepted ·" + << "Firmware bundle/probe contract: PASS · P4 revision + flash progress formats accepted ·" << firmware.bundleStatus(); return 0; } From 3e2e20adc4f6c97469c75a155b7d092e0191fc76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:00:24 +0700 Subject: [PATCH 090/272] docs(studio): record Smart UX checkpoint D --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index ceac39ac0..6fbb6f6c9 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -30,7 +30,7 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Current and voltage edits apply to connected firmware live. - ✅ Magnitude and phase text edits are coalesced with a 90 ms UI debounce to avoid serial command storms while preserving immediate-feeling operation. -- ✅ Frequency and signal writes now pass through a bounded native last-value-wins queue in `StudioDeviceController`. +- ✅ Frequency and signal writes pass through a bounded native last-value-wins queue in `StudioDeviceController`. - ✅ Native queue capacity is bounded by design to one pending frequency plus the eight fixed 4I+4V channel IDs; it cannot grow with repeated typing. - ✅ Native live writes flush on a short timer, and START forces the newest queued values to the device before enabling output. - ✅ Disconnect/unverify clears pending live writes; ZERO cancels pending channel writes so an old queued value cannot reappear after zeroing. @@ -47,9 +47,11 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Friendly `Firmware update -> Update now?` operator prompt is implemented. - 🟡 One-click update coordinator is implemented: safe STOP if required -> release serial -> ROM probe -> verified bundled flash -> reset -> reconnect -> verify semantic identity -> prepare 4I+4V. - 🟡 If automatic ROM entry fails, normal UI falls back to BOOT + RESET guidance and `Retry update`. -- 🟡 Update UI currently uses an indeterminate progress bar. Actual numeric flash percentage is not parsed yet. +- ✅ Smart Session and Advanced now share one application-wide `FirmwareService`; package/probe/progress state has a single authority. +- ✅ Firmware Manager parses real espflash percentage tokens when available and exposes `flashProgress` 0..100; unknown formats safely remain indeterminate. +- ✅ Packaged firmware contract now regression-checks progress parsing, including safe fallback for missing/invalid percentages. +- 🟡 Firmware update UI shows numeric progress when espflash emits percentages and automatically falls back to indeterminate during probe/reset or unknown output. - ⬜ Hardware acceptance of legacy-firmware -> one-click update -> reconnect -> READY is still required. -- ⬜ Centralize the Firmware Manager instance used by Smart Session and Advanced configuration to avoid duplicate package validation objects. ## Operator UI simplification @@ -58,6 +60,7 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Advanced/recovery functionality remains available rather than being deleted. - ✅ Phasor/waveform visual noise is opt-in at startup rather than occupying the default workspace. - ✅ Firmware internals remain hidden during the normal update path; only exceptional BOOT/RESET recovery is surfaced. +- ✅ Firmware update failure now surfaces a concise operator message instead of silently dropping back to an idle state. - ⬜ Main workspace title and legacy header/footer language still need simplification. - ⬜ Remove duplicate device/status indicators and legacy keyboard-help noise from normal view. - ⬜ Advanced window needs final progressive-disclosure cleanup and larger minimum typography. @@ -74,16 +77,17 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Reconnect preparation is delayed briefly after identity verification so existing SHOW / PROFILE SHOW responses can settle before automatic profile synchronization. - ✅ Firmware update completion is not trusted until the newly reconnected firmware reports the expected semantic version. - ✅ Firmware update state exits deterministically on probe rejection/failure instead of hanging indefinitely. +- ✅ Post-flash reconnect is bounded to six retry windows and waits for active discovery/verification instead of launching overlapping probes. - ✅ Live-edit write backlog is bounded and last-value-wins instead of growing with operator keystrokes. -- ⬜ Add bounded retry/backoff for disconnect/reconnect and serial errors. +- 🟡 General hot-plug discovery remains periodic and lightweight; hardware acceptance is still needed for repeated unplug/replug cycles. - ⬜ Add regression tests for USB removal during READY and RUNNING. - ⬜ Add regression tests for malformed/slow serial responses and command timeouts. - ⬜ Add soak test for continuous 4000 fps operation plus repeated live edits. ## Current checkpoint -Checkpoint C — **zero-configuration workflow + semantic firmware update + bounded native live-write coalescing** is implemented in the branch. +Checkpoint D — **zero-configuration workflow + semantic firmware update + shared firmware authority + bounded native live writes + bounded post-flash reconnect + numeric flash progress** is implemented in the branch. Current validation state: **CI and hardware acceptance pending for the newest Smart UX head.** Do not treat this checkpoint as release-ready until those gates pass. -Next implementation checkpoint after CI: final main-window visual cleanup, centralize firmware-manager ownership, bounded reconnect/backoff, then hardware acceptance of both current-firmware and legacy-firmware paths. +Next implementation checkpoint after CI: final main-window visual cleanup and failure-path regression coverage, then hardware acceptance of both current-firmware and legacy-firmware paths. From ea6c51f4c9ce18b7bd5f64417cee3e7908203ca0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:09:47 +0700 Subject: [PATCH 091/272] polish(studio): simplify advanced configuration hierarchy --- apps/arstack_studio/qml/ConfigurationHub.qml | 90 +++++++++++++------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index d0fef5b3d..697279b6e 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -17,14 +17,15 @@ Item { function showRecovery() { expertTabs.currentIndex = 1 } Component.onCompleted: { + // Recovery is the only advanced page an unconfigured board should need. if (!hub.device.deviceVerified) expertTabs.currentIndex = 1 } component ExpertTab: TabButton { - implicitHeight: 38 + implicitHeight: 42 font.family: hub.uiFont - font.pixelSize: 10 + font.pixelSize: 11 font.weight: checked ? Font.DemiBold : Font.Medium contentItem: Label { text: parent.text @@ -34,16 +35,17 @@ Item { verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.checked ? hub.theme.raised : hub.theme.surface2 - border.width: 1 - border.color: parent.checked ? hub.theme.line : hub.theme.lineSoft + color: parent.checked ? hub.theme.raised : "transparent" + radius: 6 + border.width: parent.checked ? 1 : 0 + border.color: hub.theme.line Rectangle { visible: parent.parent.checked anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - anchors.leftMargin: 24 - anchors.rightMargin: 24 + anchors.leftMargin: 26 + anchors.rightMargin: 26 height: 2 color: hub.theme.accent } @@ -52,52 +54,76 @@ Item { ColumnLayout { anchors.fill: parent - spacing: 10 + spacing: 12 RowLayout { Layout.fillWidth: true - Layout.leftMargin: 6 - Layout.rightMargin: 6 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.topMargin: 4 spacing: 12 + ColumnLayout { Layout.fillWidth: true - spacing: 1 + spacing: 2 Label { - text: "ADVANCED" - color: hub.theme.muted + text: "Advanced settings" + color: hub.theme.text font.family: hub.uiFont - font.pixelSize: 9 - font.weight: Font.Bold - font.letterSpacing: 0.9 + font.pixelSize: 18 + font.weight: Font.DemiBold } Label { - text: "Device & engineering settings" - color: hub.theme.text + text: "Firmware recovery, engineering profile, waveform and timing tools" + color: hub.theme.muted font.family: hub.uiFont - font.pixelSize: 16 - font.weight: Font.DemiBold + font.pixelSize: 11 } } - Label { - text: hub.device.deviceVerified - ? "ESP32-P4 · protocol v" + hub.device.protocolVersion + " · ID " + hub.device.deviceId.slice(-6) - : "Recovery available" - color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber - font.family: hub.uiFont - font.pixelSize: 10 - font.weight: Font.DemiBold + + Rectangle { + implicitWidth: 112 + implicitHeight: 30 + radius: 15 + color: hub.device.deviceVerified ? "#10251d" : "#261f13" + border.width: 1 + border.color: hub.device.deviceVerified ? "#347a59" : "#705827" + + RowLayout { + anchors.centerIn: parent + spacing: 6 + Rectangle { + width: 7 + height: 7 + radius: 4 + color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber + } + Label { + text: hub.device.deviceVerified ? "Device ready" : "Setup mode" + color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber + font.family: hub.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + } + } } } TabBar { id: expertTabs Layout.fillWidth: true - implicitHeight: 38 - background: Rectangle { color: hub.theme.surface2; radius: 7 } - ExpertTab { text: "SV Setup" } + implicitHeight: 42 + spacing: 4 + background: Rectangle { + color: hub.theme.surface2 + radius: 8 + border.width: 1 + border.color: hub.theme.lineSoft + } + ExpertTab { text: "Injection" } ExpertTab { text: "Firmware" } ExpertTab { text: "Waveform" } - ExpertTab { text: "PTP Lab" } + ExpertTab { text: "Timing" } ExpertTab { text: "Device" } } From fb002e31557bfde52508d9176348ea007210b2ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:10:57 +0700 Subject: [PATCH 092/272] polish(studio): make smart workflow operator-first --- apps/arstack_studio/qml/WorkflowBar.qml | 147 ++++++++++++++---------- 1 file changed, 85 insertions(+), 62 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 4d5cbbbc5..0142885b1 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -41,9 +41,9 @@ SurfacePanel { function onFirmwareUpdateFinished(success) { if (success) { ribbon.updatePromptDeferred = false - controller.showMessage("Firmware updated. ARStack Studio is preparing 4I+4V injection.", false) + controller.showMessage("Firmware updated. Preparing injection…", false) } else { - controller.showMessage("Firmware update did not complete. Open Advanced only if recovery help is needed.", true) + controller.showMessage("Firmware update did not complete. Open Advanced if recovery help is needed.", true) } } } @@ -56,11 +56,45 @@ SurfacePanel { } } + readonly property string displayState: { + if (smartSession.state === "WAITING FOR DEVICE") return "Connect device" + if (smartSession.state === "DEVICE FOUND" || smartSession.state === "CONNECTING") return "Connecting" + if (smartSession.state === "FIRMWARE UPDATE") return "Firmware update available" + if (smartSession.state === "UPDATING FIRMWARE") return "Updating firmware" + if (smartSession.state === "UPDATE NEEDS BOOT") return "Download mode required" + if (smartSession.state === "PREPARING 4I+4V") return "Preparing" + if (smartSession.state === "READY") return "Ready" + if (smartSession.state === "RUNNING") return "Running" + if (smartSession.state === "PROFILE BLOCKED") return "Profile unavailable" + if (smartSession.state === "SETUP ERROR") return "Setup issue" + return smartSession.state + } + + readonly property color smartStateColor: { + if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green + if (smartSession.state === "CONNECTING" || smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || + smartSession.state === "UPDATE NEEDS BOOT") return theme.amber + if (smartSession.state === "DEVICE FOUND") return theme.accent + if (smartSession.state === "PROFILE BLOCKED" || smartSession.state === "SETUP ERROR") return theme.red + return theme.muted + } + + function startReason() { + if (device.running) return "SMV output is already running." + if (smartSession.firmwareUpdateRequired) return "Update firmware before starting injection." + if (smartSession.updatingFirmware) return "Firmware update is in progress." + if (smartSession.state === "CONNECTING") return "Connecting to the device automatically." + if (smartSession.state === "PREPARING 4I+4V") return "Preparing the default 4I+4V injection automatically." + if (smartSession.state === "WAITING FOR DEVICE") return "Connect ESP32-P4; Studio will detect it automatically." + return smartSession.statusText + } + Dialog { id: updateDialog modal: true anchors.centerIn: Overlay.overlay - width: 430 + width: 400 title: "Firmware update" closePolicy: Popup.NoAutoClose @@ -75,9 +109,7 @@ SurfacePanel { spacing: 12 Label { Layout.fillWidth: true - text: smartSession.deviceFirmwareVersion.length - ? "ESP32-P4 is running ARStack firmware v" + smartSession.deviceFirmwareVersion + "." - : "ESP32-P4 is running legacy ARStack firmware." + text: "A newer ARStack firmware is included with this Studio build." color: ribbon.theme.text font.family: ribbon.uiFont font.pixelSize: 13 @@ -86,20 +118,12 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "Update to v" + smartSession.expectedFirmwareVersion + " now? The firmware is already included with ARStack Studio." + text: "Update now? Studio will install it, restart the board, reconnect, and return to Ready automatically." color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 wrapMode: Text.WordWrap } - Label { - Layout.fillWidth: true - text: "SMV output will be stopped safely if required. Studio will flash, reset, reconnect, and prepare the default 4I+4V profile automatically." - color: ribbon.theme.muted - font.family: ribbon.uiFont - font.pixelSize: 10 - wrapMode: Text.WordWrap - } RowLayout { Layout.fillWidth: true Item { Layout.fillWidth: true } @@ -115,15 +139,15 @@ SurfacePanel { CalmButton { theme: ribbon.theme uiFont: ribbon.uiFont - text: "Update now" + text: "Update" tone: "accent" - implicitWidth: 118 + implicitWidth: 110 onClicked: { if (smartSession.beginFirmwareUpdate()) { ribbon.updatePromptDeferred = false updateDialog.close() } else { - controller.showMessage("Firmware update could not start. Open Advanced for recovery details.", true) + controller.showMessage("Firmware update could not start. Open Advanced for recovery help.", true) } } } @@ -131,27 +155,7 @@ SurfacePanel { } } - readonly property color smartStateColor: { - if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green - if (smartSession.state === "CONNECTING" || smartSession.state === "PREPARING 4I+4V" || - smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || - smartSession.state === "UPDATE NEEDS BOOT") return theme.amber - if (smartSession.state === "DEVICE FOUND") return theme.accent - if (smartSession.state === "PROFILE BLOCKED" || smartSession.state === "SETUP ERROR") return theme.red - return theme.muted - } - - function startReason() { - if (device.running) return "SMV output is already running." - if (smartSession.firmwareUpdateRequired) return "Firmware update is required before injection." - if (smartSession.updatingFirmware) return "Firmware update is in progress." - if (smartSession.state === "CONNECTING") return "ARStack Studio is connecting automatically." - if (smartSession.state === "PREPARING 4I+4V") return "Preparing the default 4I+4V profile automatically…" - if (smartSession.state === "WAITING FOR DEVICE") return "Connect the ESP32-P4; ARStack Studio will detect it automatically." - return smartSession.statusText - } - - implicitHeight: 94 + implicitHeight: 88 color: theme.surface2 border.color: theme.line @@ -165,11 +169,11 @@ SurfacePanel { ColumnLayout { anchors.fill: parent - anchors.leftMargin: 11 - anchors.rightMargin: 11 + anchors.leftMargin: 12 + anchors.rightMargin: 12 anchors.topMargin: 8 anchors.bottomMargin: 8 - spacing: 7 + spacing: 6 RowLayout { Layout.fillWidth: true @@ -183,30 +187,39 @@ SurfacePanel { color: ribbon.smartStateColor } Label { - text: smartSession.state + text: ribbon.displayState color: ribbon.smartStateColor font.family: ribbon.uiFont - font.pixelSize: 10 - font.weight: Font.Bold - font.letterSpacing: 0.45 + font.pixelSize: 11 + font.weight: Font.DemiBold } Label { - visible: !ribbon.compact + visible: !ribbon.compact && smartSession.state !== "READY" && smartSession.state !== "RUNNING" text: smartSession.statusText color: ribbon.theme.muted font.family: ribbon.uiFont - font.pixelSize: 9 + font.pixelSize: 10 elide: Text.ElideRight Layout.fillWidth: true } + Label { + visible: !ribbon.compact && (smartSession.state === "READY" || smartSession.state === "RUNNING") + text: smartSession.state === "RUNNING" + ? "4I + 4V · 4000 samples/s · live" + : "4I + 4V · 4000 samples/s" + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 10 + Layout.fillWidth: true + } Item { Layout.fillWidth: ribbon.compact } CalmButton { theme: ribbon.theme uiFont: ribbon.uiFont text: "Advanced" implicitHeight: 28 - font.pixelSize: 9 - toolTipText: "Firmware, SCL, waveform stress, PTP and diagnostics" + font.pixelSize: 10 + toolTipText: "Firmware, engineering profile, waveform and timing tools" onClicked: ribbon.controller.openConfiguration() } } @@ -217,21 +230,26 @@ SurfacePanel { spacing: 6 ActionButton { + visible: !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Balanced" iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") toolTipText: "Apply balanced three-phase values" - enabled: !smartSession.updatingFirmware onClicked: ribbon.controller.balanced() } ActionButton { + visible: !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Zero" iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") - toolTipText: "Set every current and voltage magnitude to zero" - enabled: !smartSession.updatingFirmware + toolTipText: "Set all current and voltage magnitudes to zero" onClicked: ribbon.controller.zeroAll() } - Rectangle { width: 1; height: 28; color: ribbon.theme.lineSoft } + Rectangle { + visible: !ribbon.compact && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp + width: 1 + height: 28 + color: ribbon.theme.lineSoft + } ActionButton { visible: !ribbon.compact && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp @@ -259,14 +277,19 @@ SurfacePanel { RowLayout { visible: smartSession.updatingFirmware spacing: 8 + Label { + visible: !ribbon.compact + text: "Updating firmware" + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 10 + } ProgressBar { - id: firmwareProgress indeterminate: FirmwareService.flashProgress < 0 from: 0 to: 100 value: FirmwareService.flashProgress < 0 ? 0 : FirmwareService.flashProgress Layout.preferredWidth: ribbon.compact ? 125 : 190 - Layout.alignment: Qt.AlignVCenter } Label { visible: FirmwareService.flashProgress >= 0 @@ -282,7 +305,7 @@ SurfacePanel { Label { visible: smartSession.updateNeedsBootloaderHelp - text: "BOOT + RESET required" + text: ribbon.compact ? "BOOT + RESET" : "Put the board in Download mode" color: ribbon.theme.amber font.family: ribbon.uiFont font.pixelSize: 10 @@ -290,9 +313,9 @@ SurfacePanel { } ActionButton { visible: smartSession.updateNeedsBootloaderHelp - text: "Retry update" + text: "Retry" tone: "accent" - implicitWidth: 118 + implicitWidth: 96 toolTipText: smartSession.updateStatus onClicked: smartSession.retryFirmwareUpdate() } @@ -301,7 +324,7 @@ SurfacePanel { visible: smartSession.firmwareUpdateRequired && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Update firmware" tone: "accent" - implicitWidth: 132 + implicitWidth: 136 onClicked: { ribbon.updatePromptDeferred = false updateDialog.open() @@ -314,7 +337,7 @@ SurfacePanel { text: "Start" iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") tone: "success" - implicitWidth: 132 + implicitWidth: 140 enabled: smartSession.startReady toolTipText: ribbon.startReason() onClicked: device.start() @@ -324,7 +347,7 @@ SurfacePanel { text: "Stop" iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") tone: "danger" - implicitWidth: 132 + implicitWidth: 140 enabled: device.deviceVerified toolTipText: "Stop Sampled Values output" onClicked: device.stop() From 548e75572b26aa9b6efe2252c41e76e6edfee92e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:11:35 +0700 Subject: [PATCH 093/272] polish(studio): reduce telemetry visual noise --- apps/arstack_studio/qml/TelemetryDock.qml | 146 ++++++++++------------ 1 file changed, 66 insertions(+), 80 deletions(-) diff --git a/apps/arstack_studio/qml/TelemetryDock.qml b/apps/arstack_studio/qml/TelemetryDock.qml index a48b2ce15..61cd0331e 100644 --- a/apps/arstack_studio/qml/TelemetryDock.qml +++ b/apps/arstack_studio/qml/TelemetryDock.qml @@ -13,18 +13,21 @@ Rectangle { property var historyModel property string uiFont: "Inter" property string monoFont: "Inter" - property bool expanded: true + property bool expanded: false signal closeRequested() - implicitHeight: 32 - Layout.preferredHeight: expanded ? 124 : 32 + readonly property bool hasRuntimeTelemetry: + telemetry.device.fps !== "—" || telemetry.device.missed !== "—" || telemetry.device.txFailures !== "—" + + implicitHeight: 34 + Layout.preferredHeight: expanded ? 132 : 34 color: theme.surface radius: theme.panelRadius border.width: 1 border.color: theme.line clip: true - Behavior on Layout.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } + Behavior on Layout.preferredHeight { NumberAnimation { duration: 180; easing.type: Easing.InOutCubic } } ColumnLayout { anchors.fill: parent @@ -33,7 +36,7 @@ Rectangle { Rectangle { id: headerBar Layout.fillWidth: true - Layout.preferredHeight: 32 + Layout.preferredHeight: 34 color: headerMouse.containsMouse ? theme.raisedHover : theme.raised Behavior on color { ColorAnimation { duration: 90 } } @@ -47,35 +50,36 @@ Rectangle { RowLayout { anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 6 - spacing: 7 + anchors.leftMargin: 11 + anchors.rightMargin: 7 + spacing: 8 - Rectangle { width: 3; height: 14; radius: 2; color: telemetry.device.running ? theme.green : theme.accent } + Rectangle { + width: 7 + height: 7 + radius: 4 + color: telemetry.device.running ? theme.green : (telemetry.device.deviceVerified ? theme.accent : theme.muted2) + } Label { - text: "Status & output monitor" - color: theme.textSoft + text: telemetry.device.running ? "Injection running" : (telemetry.device.deviceVerified ? "Device ready" : "Monitor") + color: telemetry.device.running ? theme.green : theme.textSoft font.family: telemetry.uiFont - font.pixelSize: theme.labelSize + font.pixelSize: 10 font.weight: Font.DemiBold verticalAlignment: Text.AlignVCenter } - Label { - text: telemetry.device.running ? "LIVE" : "STANDBY" - color: telemetry.device.running ? theme.green : theme.muted - font.family: telemetry.monoFont - font.pixelSize: theme.captionSize - 1 - font.weight: Font.Bold - verticalAlignment: Text.AlignVCenter - } + Item { Layout.fillWidth: true } + Label { - text: "FPS " + telemetry.device.fps + " · MISSED " + telemetry.device.missed + " · TX FAIL " + telemetry.device.txFailures + visible: telemetry.device.running && telemetry.hasRuntimeTelemetry + text: telemetry.device.fps + " fps · missed " + telemetry.device.missed + " · tx fail " + telemetry.device.txFailures color: theme.muted font.family: telemetry.monoFont - font.pixelSize: theme.captionSize - 1 + font.pixelSize: 9 verticalAlignment: Text.AlignVCenter } + DarkToolButton { theme: telemetry.theme uiFont: telemetry.uiFont @@ -84,24 +88,13 @@ Rectangle { : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") onClicked: telemetry.expanded = !telemetry.expanded ToolTip.visible: hovered - ToolTip.text: telemetry.expanded ? "Collapse monitor" : "Expand monitor" - } - DarkToolButton { - theme: telemetry.theme - uiFont: telemetry.uiFont - iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") - iconSize: 19 - onClicked: telemetry.closeRequested() - ToolTip.visible: hovered - ToolTip.text: "Close monitor" + ToolTip.text: telemetry.expanded ? "Hide monitor details" : "Show monitor details" } } } RowLayout { - visible: opacity > 0 - opacity: telemetry.expanded ? 1 : 0 - Behavior on opacity { NumberAnimation { duration: telemetry.expanded ? 180 : 110; easing.type: Easing.OutCubic } } + visible: telemetry.expanded Layout.fillWidth: true Layout.fillHeight: true Layout.margins: 9 @@ -110,7 +103,7 @@ Rectangle { Rectangle { Layout.fillWidth: true Layout.fillHeight: true - Layout.preferredWidth: 1.25 + Layout.preferredWidth: 1.35 radius: theme.controlRadius color: theme.surface2 border.width: 1 @@ -118,39 +111,49 @@ Rectangle { ColumnLayout { anchors.fill: parent - anchors.margins: 8 - spacing: 3 + anchors.margins: 9 + spacing: 5 Label { - text: "STATUS HISTORY" - color: theme.muted + text: "Recent activity" + color: theme.textSoft font.family: telemetry.uiFont - font.pixelSize: theme.captionSize - 1 + font.pixelSize: 10 font.weight: Font.DemiBold - font.letterSpacing: 0.8 } ListView { Layout.fillWidth: true Layout.fillHeight: true model: telemetry.historyModel clip: true - spacing: 2 + spacing: 3 delegate: RowLayout { required property string timeText required property string messageText required property bool isError width: ListView.view.width - spacing: 7 - Label { text: timeText; color: theme.muted; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize - 1 } - Label { Layout.fillWidth: true; text: messageText; color: isError ? theme.red : theme.textSoft; font.family: telemetry.uiFont; font.pixelSize: theme.captionSize; elide: Text.ElideRight } + spacing: 8 + Label { + text: timeText + color: theme.muted + font.family: telemetry.monoFont + font.pixelSize: 9 + } + Label { + Layout.fillWidth: true + text: messageText + color: isError ? theme.red : theme.textSoft + font.family: telemetry.uiFont + font.pixelSize: 10 + elide: Text.ElideRight + } } } } } Rectangle { - Layout.fillWidth: true + Layout.preferredWidth: 250 Layout.fillHeight: true - Layout.preferredWidth: 0.75 radius: theme.controlRadius color: theme.surface2 border.width: 1 @@ -158,43 +161,26 @@ Rectangle { ColumnLayout { anchors.fill: parent - anchors.margins: 8 - spacing: 4 + anchors.margins: 10 + spacing: 7 Label { - text: "OUTPUT CHANNELS" - color: theme.muted + text: "Transmission" + color: theme.textSoft font.family: telemetry.uiFont - font.pixelSize: theme.captionSize - 1 + font.pixelSize: 10 font.weight: Font.DemiBold - font.letterSpacing: 0.8 } - RowLayout { + GridLayout { Layout.fillWidth: true - spacing: 10 - Repeater { - model: telemetry.currentModel - delegate: RowLayout { - required property string signalId - required property bool enabled - spacing: 4 - Rectangle { width: 7; height: 7; radius: 4; color: enabled ? theme.green : theme.muted2 } - Label { text: signalId; color: theme.textSoft; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize } - } - } - } - RowLayout { - Layout.fillWidth: true - spacing: 10 - Repeater { - model: telemetry.voltageModel - delegate: RowLayout { - required property string signalId - required property bool enabled - spacing: 4 - Rectangle { width: 7; height: 7; radius: 4; color: enabled ? theme.accent : theme.muted2 } - Label { text: signalId; color: theme.textSoft; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize } - } - } + columns: 2 + columnSpacing: 16 + rowSpacing: 5 + Label { text: "Rate"; color: theme.muted; font.family: telemetry.uiFont; font.pixelSize: 9 } + Label { text: telemetry.device.fps === "—" ? "—" : telemetry.device.fps + " fps"; color: theme.text; font.family: telemetry.monoFont; font.pixelSize: 10 } + Label { text: "Missed"; color: theme.muted; font.family: telemetry.uiFont; font.pixelSize: 9 } + Label { text: telemetry.device.missed; color: telemetry.device.missed === "0" ? theme.green : theme.text; font.family: telemetry.monoFont; font.pixelSize: 10 } + Label { text: "TX failures"; color: theme.muted; font.family: telemetry.uiFont; font.pixelSize: 9 } + Label { text: telemetry.device.txFailures; color: telemetry.device.txFailures === "0" ? theme.green : theme.text; font.family: telemetry.monoFont; font.pixelSize: 10 } } } } From fd4fabae84417c6afbedf39004efb0fdd6013cdc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:12:08 +0700 Subject: [PATCH 094/272] polish(studio): strengthen numeric value hierarchy --- apps/arstack_studio/qml/NumericField.qml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/arstack_studio/qml/NumericField.qml b/apps/arstack_studio/qml/NumericField.qml index f960d5c7e..91cf9c538 100644 --- a/apps/arstack_studio/qml/NumericField.qml +++ b/apps/arstack_studio/qml/NumericField.qml @@ -18,29 +18,29 @@ TextField { selectionColor: theme.accent selectedTextColor: theme.bg font.family: monoFont - font.pixelSize: compact ? 10 : 12 + font.pixelSize: compact ? 12 : 14 font.weight: Font.DemiBold - leftPadding: 8 - rightPadding: suffixText.length > 0 ? 29 : 8 - implicitHeight: compact ? 31 : 34 + leftPadding: 10 + rightPadding: suffixText.length > 0 ? 34 : 10 + implicitHeight: compact ? 35 : 39 Label { visible: field.suffixText.length > 0 anchors.right: parent.right - anchors.rightMargin: 8 + anchors.rightMargin: 9 anchors.verticalCenter: parent.verticalCenter text: field.suffixText color: field.activeFocus ? field.theme.textSoft : field.theme.muted font.family: field.monoFont - font.pixelSize: field.compact ? 8 : 9 + font.pixelSize: field.compact ? 9 : 10 font.weight: Font.Medium Behavior on color { ColorAnimation { duration: 90 } } } background: Rectangle { - radius: 6 + radius: 7 color: field.activeFocus ? "#0b141d" : (field.hovered ? "#121c26" : "#10171f") - border.width: 1 + border.width: field.activeFocus ? 2 : 1 border.color: field.invalidInput ? field.theme.red : field.activeFocus ? field.theme.accent : field.hovered ? "#334252" : field.theme.lineSoft From 6b2e29361e7b040fcf771fdfa8282e8aa4ec05a2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:12:38 +0700 Subject: [PATCH 095/272] polish(studio): make injection matrix easier to scan --- apps/arstack_studio/qml/SignalMatrix.qml | 62 ++++++++++++------------ 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index a72dc05ae..998015272 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -18,7 +18,7 @@ Rectangle { property bool compact: false color: theme.surface2 - radius: 8 + radius: 9 border.width: 1 border.color: theme.lineSoft @@ -41,15 +41,15 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 40 - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 8 + Layout.preferredHeight: 44 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 9 Rectangle { - width: 20 - height: 20 - radius: 5 + width: 23 + height: 23 + radius: 6 color: "#122033" border.width: 1 border.color: "#223955" @@ -58,7 +58,7 @@ Rectangle { text: matrix.symbolText color: matrix.theme.accent font.family: matrix.monoFont - font.pixelSize: 9 + font.pixelSize: 11 font.weight: Font.Bold } } @@ -66,7 +66,7 @@ Rectangle { text: matrix.titleText color: matrix.theme.text font.family: matrix.uiFont - font.pixelSize: 11 + font.pixelSize: 13 font.weight: Font.DemiBold verticalAlignment: Text.AlignVCenter } @@ -75,7 +75,7 @@ Rectangle { text: matrix.unitText color: matrix.theme.muted font.family: matrix.uiFont - font.pixelSize: 9 + font.pixelSize: 10 font.weight: Font.Medium verticalAlignment: Text.AlignVCenter } @@ -85,14 +85,14 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 26 - Layout.leftMargin: 9 - Layout.rightMargin: 9 - spacing: 7 - Label { text: "ON"; Layout.preferredWidth: 28; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - Label { text: "CH"; Layout.preferredWidth: 34; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - Label { text: "MAGNITUDE"; Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - Label { text: "PHASE"; Layout.preferredWidth: matrix.compact ? 82 : 98; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Layout.preferredHeight: 28 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + spacing: 8 + Item { Layout.preferredWidth: 28 } + Label { text: "Channel"; Layout.preferredWidth: 46; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "Value"; Layout.fillWidth: true; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "Phase"; Layout.preferredWidth: matrix.compact ? 92 : 108; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } } Repeater { @@ -115,7 +115,7 @@ Rectangle { property real pendingPhase: angle Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 50 : 54 + Layout.preferredHeight: matrix.compact ? 54 : 60 color: signalRow.selected ? "#111c27" : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#131f2b" : rowHover.hovered ? "#101820" : "transparent" @@ -150,14 +150,16 @@ Rectangle { RowLayout { anchors.fill: parent - anchors.leftMargin: 9 - anchors.rightMargin: 9 - spacing: 7 + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 CheckBox { Layout.preferredWidth: 28 Layout.alignment: Qt.AlignVCenter checked: signalRow.channelEnabled + ToolTip.visible: hovered + ToolTip.text: checked ? "Channel enabled" : "Channel disabled" onToggled: { matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) @@ -168,15 +170,15 @@ Rectangle { } RowLayout { - Layout.preferredWidth: 34 + Layout.preferredWidth: 46 Layout.alignment: Qt.AlignVCenter - spacing: 5 - Rectangle { width: 7; height: 7; radius: 3.5; color: signalRow.phaseColor } + spacing: 6 + Rectangle { width: 8; height: 8; radius: 4; color: signalRow.phaseColor } Label { text: signalRow.sid color: signalRow.selected ? matrix.theme.text : matrix.theme.textSoft font.family: matrix.monoFont - font.pixelSize: 9 + font.pixelSize: 11 font.weight: Font.Bold verticalAlignment: Text.AlignVCenter } @@ -214,7 +216,7 @@ Rectangle { magnitudeApplyTimer.stop() text = signalRow.mag.toFixed(3) invalidInput = false - matrix.controller.showMessage(signalRow.sid + " magnitude is outside the valid wire/scaling range.", true) + matrix.controller.showMessage(signalRow.sid + " value is outside the supported range.", true) } else { signalRow.pendingMagnitude = value magnitudeApplyTimer.stop() @@ -236,7 +238,7 @@ Rectangle { NumericField { id: phaseField enabled: matrix.controller.signalFrequency > 0 - Layout.preferredWidth: matrix.compact ? 82 : 98 + Layout.preferredWidth: matrix.compact ? 92 : 108 theme: matrix.theme monoFont: matrix.monoFont compact: matrix.compact @@ -266,7 +268,7 @@ Rectangle { phaseApplyTimer.stop() text = signalRow.angle.toFixed(2) invalidInput = false - matrix.controller.showMessage(signalRow.sid + " phase must stay within ±360000°.", true) + matrix.controller.showMessage(signalRow.sid + " phase is outside the supported range.", true) } else { signalRow.pendingPhase = value phaseApplyTimer.stop() From 3f56e2ba9ba925adfce95efdcac780c3c594a592 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:13:22 +0700 Subject: [PATCH 096/272] docs(studio): record checkpoint E smart UX polish --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index 6fbb6f6c9..9a774b47e 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -34,6 +34,8 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Native queue capacity is bounded by design to one pending frequency plus the eight fixed 4I+4V channel IDs; it cannot grow with repeated typing. - ✅ Native live writes flush on a short timer, and START forces the newest queued values to the device before enabling output. - ✅ Disconnect/unverify clears pending live writes; ZERO cancels pending channel writes so an old queued value cannot reappear after zeroing. +- ✅ Numeric controls now use a stronger visual hierarchy (larger value text, clearer focus state, larger suffixes) because injection values are the primary operator task. +- ✅ Current/voltage matrices use larger channel labels, roomier rows, simpler `Channel / Value / Phase` headings, and clearer operator-facing validation messages. - 🟡 Native live-write coalescing still needs hardware feel/latency acceptance during rapid edits. ## Firmware intelligence @@ -44,27 +46,30 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ ESP-IDF project version is pinned to `0.1.0` for deterministic semantic firmware identity. - ✅ New firmware `IDENTIFY` appends `firmware=` and `capabilities=SMV-4I4V,LIVE-SETPOINTS` while preserving the older identity prefix for backwards compatibility. - ✅ Smart session distinguishes current firmware from legacy/outdated firmware. Missing semantic version is treated as legacy rather than silently accepted. -- ✅ Friendly `Firmware update -> Update now?` operator prompt is implemented. +- ✅ Friendly firmware-update prompt is implemented with concise operator wording. - 🟡 One-click update coordinator is implemented: safe STOP if required -> release serial -> ROM probe -> verified bundled flash -> reset -> reconnect -> verify semantic identity -> prepare 4I+4V. -- 🟡 If automatic ROM entry fails, normal UI falls back to BOOT + RESET guidance and `Retry update`. -- ✅ Smart Session and Advanced now share one application-wide `FirmwareService`; package/probe/progress state has a single authority. +- 🟡 If automatic ROM entry fails, normal UI falls back to BOOT + RESET guidance and `Retry`. +- ✅ Smart Session and Advanced share one application-wide `FirmwareService`; package/probe/progress state has a single authority. - ✅ Firmware Manager parses real espflash percentage tokens when available and exposes `flashProgress` 0..100; unknown formats safely remain indeterminate. -- ✅ Packaged firmware contract now regression-checks progress parsing, including safe fallback for missing/invalid percentages. +- ✅ Packaged firmware contract regression-checks progress parsing, including safe fallback for missing/invalid percentages. - 🟡 Firmware update UI shows numeric progress when espflash emits percentages and automatically falls back to indeterminate during probe/reset or unknown output. - ⬜ Hardware acceptance of legacy-firmware -> one-click update -> reconnect -> READY is still required. ## Operator UI simplification - ✅ Primary ribbon reduced to smart state + Balanced + Zero + optional views + Advanced + START/STOP. +- ✅ Smart-session enum names are translated to operator-facing states such as `Connect device`, `Connecting`, `Preparing`, `Ready`, `Running`, and `Firmware update available`. +- ✅ Firmware update dialog is shortened to one decision and hides bootloader/SHA/protocol details unless recovery fails. - ✅ Low-level profile deployment is hidden from normal operation. - ✅ Advanced/recovery functionality remains available rather than being deleted. - ✅ Phasor/waveform visual noise is opt-in at startup rather than occupying the default workspace. - ✅ Firmware internals remain hidden during the normal update path; only exceptional BOOT/RESET recovery is surfaced. -- ✅ Firmware update failure now surfaces a concise operator message instead of silently dropping back to an idle state. -- ⬜ Main workspace title and legacy header/footer language still need simplification. -- ⬜ Remove duplicate device/status indicators and legacy keyboard-help noise from normal view. -- ⬜ Advanced window needs final progressive-disclosure cleanup and larger minimum typography. -- ⬜ Review default window density at 100%, 125%, 150%, and laptop resolutions. +- ✅ Firmware update failure surfaces a concise operator message instead of silently dropping back to an idle state. +- ✅ Advanced configuration header/typography is simplified and enlarged; tabs are now `Injection / Firmware / Waveform / Timing / Device` with a single `Device ready / Setup mode` status pill. +- ✅ Telemetry is collapsed by default and reduced to one quiet status line; expanded mode contains only `Recent activity` and `Transmission` instead of duplicating channel state. +- ⬜ Main workspace title and legacy top-level header/footer language still need simplification. +- ⬜ Remove the remaining duplicate top-level device indicator and legacy keyboard-help noise from `Main.qml` normal view. +- ⬜ Review default window density at 100%, 125%, 150%, and laptop resolutions after a packaged build is available. ## Robustness / crash resistance @@ -80,14 +85,15 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Post-flash reconnect is bounded to six retry windows and waits for active discovery/verification instead of launching overlapping probes. - ✅ Live-edit write backlog is bounded and last-value-wins instead of growing with operator keystrokes. - 🟡 General hot-plug discovery remains periodic and lightweight; hardware acceptance is still needed for repeated unplug/replug cycles. +- ⬜ Add a firmware session lease/watchdog so a GUI crash or control-link loss cannot leave an operator-owned RUN session transmitting indefinitely. - ⬜ Add regression tests for USB removal during READY and RUNNING. - ⬜ Add regression tests for malformed/slow serial responses and command timeouts. - ⬜ Add soak test for continuous 4000 fps operation plus repeated live edits. ## Current checkpoint -Checkpoint D — **zero-configuration workflow + semantic firmware update + shared firmware authority + bounded native live writes + bounded post-flash reconnect + numeric flash progress** is implemented in the branch. +Checkpoint E — **zero-configuration workflow + semantic firmware update + bounded live writes/reconnect + numeric firmware progress + operator-first visual hierarchy** is implemented in the branch. Current validation state: **CI and hardware acceptance pending for the newest Smart UX head.** Do not treat this checkpoint as release-ready until those gates pass. -Next implementation checkpoint after CI: final main-window visual cleanup and failure-path regression coverage, then hardware acceptance of both current-firmware and legacy-firmware paths. +Next implementation checkpoint after CI: top-level `Main.qml` chrome cleanup and failure-path regression coverage. The firmware session lease/watchdog should be implemented before claiming crash-safe RUN behavior, then both current-firmware and legacy-firmware paths need hardware acceptance. From 7f080d7fbbc99c5d1091990adb54e9a0d21c02a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:14:18 +0700 Subject: [PATCH 097/272] fix(studio): stop active output on graceful app exit --- apps/arstack_studio/src/StudioDeviceController.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 40a5431b2..2d1bcaf04 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -3,6 +3,7 @@ #include "DeviceController.hpp" +#include #include #include #include @@ -30,6 +31,16 @@ class StudioDeviceController : public DeviceController { connect(this, &DeviceController::deviceVerifiedChanged, this, [this] { if (!deviceVerified()) clearPendingLiveCommands(); }); + if (auto* app = QCoreApplication::instance(); app != nullptr) { + connect(app, &QCoreApplication::aboutToQuit, this, [this] { + clearPendingLiveCommands(); + if (connected() && running()) { + // Best-effort graceful shutdown. A firmware-side session lease + // is still required for hard crashes/power loss of the control PC. + static_cast(DeviceController::stop()); + } + }); + } } [[nodiscard]] bool currentFirmwareIdentitySeen() const { From 667772a42d365e9f9b6a929bef2ee59fdd526c9c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:15:32 +0700 Subject: [PATCH 098/272] docs(studio): record checkpoint F operator polish --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index 9a774b47e..3103f56c5 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -65,10 +65,12 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Phasor/waveform visual noise is opt-in at startup rather than occupying the default workspace. - ✅ Firmware internals remain hidden during the normal update path; only exceptional BOOT/RESET recovery is surfaced. - ✅ Firmware update failure surfaces a concise operator message instead of silently dropping back to an idle state. -- ✅ Advanced configuration header/typography is simplified and enlarged; tabs are now `Injection / Firmware / Waveform / Timing / Device` with a single `Device ready / Setup mode` status pill. +- ✅ Advanced configuration header/typography is simplified and enlarged; tabs are `Injection / Firmware / Waveform / Timing / Device` with one `Device ready / Setup mode` status pill. - ✅ Telemetry is collapsed by default and reduced to one quiet status line; expanded mode contains only `Recent activity` and `Transmission` instead of duplicating channel state. -- ⬜ Main workspace title and legacy top-level header/footer language still need simplification. -- ⬜ Remove the remaining duplicate top-level device indicator and legacy keyboard-help noise from `Main.qml` normal view. +- ✅ Main action ribbon now uses human-readable state labels instead of raw state-machine names and keeps Ready/Running copy intentionally short. +- ✅ Injection value fields and current/voltage matrices were visually promoted above chrome/status elements. +- ⬜ Main workspace title and the remaining legacy top-level header/footer language still need simplification in `Main.qml`. +- ⬜ Remove the remaining duplicate top-level device indicator and keyboard-help noise from `Main.qml` normal view. - ⬜ Review default window density at 100%, 125%, 150%, and laptop resolutions after a packaged build is available. ## Robustness / crash resistance @@ -84,16 +86,17 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Firmware update state exits deterministically on probe rejection/failure instead of hanging indefinitely. - ✅ Post-flash reconnect is bounded to six retry windows and waits for active discovery/verification instead of launching overlapping probes. - ✅ Live-edit write backlog is bounded and last-value-wins instead of growing with operator keystrokes. +- ✅ Normal application shutdown performs a best-effort STOP before the control process exits while output is RUNNING. - 🟡 General hot-plug discovery remains periodic and lightweight; hardware acceptance is still needed for repeated unplug/replug cycles. -- ⬜ Add a firmware session lease/watchdog so a GUI crash or control-link loss cannot leave an operator-owned RUN session transmitting indefinitely. +- ⬜ Add a firmware session lease/watchdog so a hard GUI crash or control-link loss cannot leave an operator-owned RUN session transmitting indefinitely. - ⬜ Add regression tests for USB removal during READY and RUNNING. - ⬜ Add regression tests for malformed/slow serial responses and command timeouts. - ⬜ Add soak test for continuous 4000 fps operation plus repeated live edits. ## Current checkpoint -Checkpoint E — **zero-configuration workflow + semantic firmware update + bounded live writes/reconnect + numeric firmware progress + operator-first visual hierarchy** is implemented in the branch. +Checkpoint F — **zero-configuration workflow + semantic firmware update + bounded live writes/reconnect + numeric firmware progress + operator-first visual hierarchy + graceful STOP on normal exit** is implemented in the branch. Current validation state: **CI and hardware acceptance pending for the newest Smart UX head.** Do not treat this checkpoint as release-ready until those gates pass. -Next implementation checkpoint after CI: top-level `Main.qml` chrome cleanup and failure-path regression coverage. The firmware session lease/watchdog should be implemented before claiming crash-safe RUN behavior, then both current-firmware and legacy-firmware paths need hardware acceptance. +Next implementation checkpoint after CI: remaining top-level `Main.qml` chrome cleanup and failure-path regression coverage. A firmware session lease/watchdog is still required before claiming hard-crash-safe RUN behavior, followed by hardware acceptance of both current-firmware and legacy-firmware paths. From 723cc938967345bbcf55a72087bbe557fb088d38 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:16:43 +0700 Subject: [PATCH 099/272] refactor(studio): expose quiet serial command for session lease --- apps/arstack_studio/src/DeviceController.hpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 0eae0a2bf..db7a2bb71 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -148,6 +148,20 @@ class DeviceController : public QObject { void ptpStateChanged(); void deviceMessage(const QString& message); +protected: + // Keep high-rate/session-control traffic out of the human diagnostics log. + // This is intentionally protected so StudioDeviceController can maintain a + // firmware session lease without making the generic controller API public. + bool sendQuietCommand(const QString& command) { + if (!serial_.isOpen()) return false; + const QByteArray bytes = command.toUtf8() + '\n'; + if (serial_.write(bytes) < 0) { + setError(serial_.errorString()); + return false; + } + return true; + } + private: bool connectPortInternal(const QString& portName, bool automatic); bool tryNextProbe(); From 54b279ccdfda09651a4b894a611de7eea39075ac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:17:23 +0700 Subject: [PATCH 100/272] feat(studio): maintain quiet firmware session lease --- .../src/StudioDeviceController.hpp | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 2d1bcaf04..42f412b2c 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -25,18 +25,44 @@ class StudioDeviceController : public DeviceController { connect(&liveFlushTimer_, &QTimer::timeout, this, [this] { static_cast(flushLiveCommands()); }); + + sessionHeartbeatTimer_.setSingleShot(false); + sessionHeartbeatTimer_.setInterval(700); + connect(&sessionHeartbeatTimer_, &QTimer::timeout, this, [this] { + if (!deviceVerified() || !connected() || !currentFirmwareIdentitySeen()) { + sessionHeartbeatTimer_.stop(); + return; + } + if (!sendQuietCommand(QStringLiteral("HEARTBEAT"))) + sessionHeartbeatTimer_.stop(); + }); + connect(this, &DeviceController::connectedChanged, this, [this] { - if (!connected()) clearPendingLiveCommands(); + if (!connected()) { + sessionHeartbeatTimer_.stop(); + clearPendingLiveCommands(); + } }); connect(this, &DeviceController::deviceVerifiedChanged, this, [this] { - if (!deviceVerified()) clearPendingLiveCommands(); + if (!deviceVerified()) { + sessionHeartbeatTimer_.stop(); + clearPendingLiveCommands(); + return; + } + ensureSessionHeartbeat(); + }); + connect(this, &DeviceController::logTextChanged, this, [this] { + // The semantic version field arrives on the same identity line that + // verifies the device. Start the lease only for current firmware so + // legacy builds are not spammed with an unknown command. + ensureSessionHeartbeat(); }); + if (auto* app = QCoreApplication::instance(); app != nullptr) { connect(app, &QCoreApplication::aboutToQuit, this, [this] { + sessionHeartbeatTimer_.stop(); clearPendingLiveCommands(); if (connected() && running()) { - // Best-effort graceful shutdown. A firmware-side session lease - // is still required for hard crashes/power loss of the control PC. static_cast(DeviceController::stop()); } }); @@ -70,6 +96,11 @@ class StudioDeviceController : public DeviceController { emit deviceMessage(QStringLiteral("ARStack Studio is still preparing the 4I+4V profile.")); return false; } + ensureSessionHeartbeat(); + if (!sessionHeartbeatTimer_.isActive()) { + emit deviceMessage(QStringLiteral("Control session could not be established before Start.")); + return false; + } if (!flushLiveCommands()) { emit deviceMessage(QStringLiteral("Latest injection values could not be sent before Start.")); return false; @@ -152,6 +183,14 @@ class StudioDeviceController : public DeviceController { double voltageCountsPerVolt{}; }; + void ensureSessionHeartbeat() { + if (!deviceVerified() || !connected() || !currentFirmwareIdentitySeen()) return; + if (!sessionHeartbeatTimer_.isActive()) { + if (sendQuietCommand(QStringLiteral("HEARTBEAT"))) + sessionHeartbeatTimer_.start(); + } + } + void scheduleLiveFlush() { if (!liveFlushTimer_.isActive()) liveFlushTimer_.start(); } @@ -196,6 +235,7 @@ class StudioDeviceController : public DeviceController { } QTimer liveFlushTimer_; + QTimer sessionHeartbeatTimer_; QHash pendingSignals_; double requestedFrequencyHz_{50.0}; double pendingFrequencyHz_{50.0}; From fcadf78acb82f35029c3c2183379420799b47c03 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:18:47 +0700 Subject: [PATCH 101/272] feat(firmware): stop Studio-owned output when session lease expires --- .../main/live_control.cpp | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index d6efa033f..130ba1243 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -7,6 +7,7 @@ #include "esp_app_desc.h" #include "esp_log.h" #include "esp_mac.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "sdkconfig.h" @@ -36,11 +37,14 @@ constexpr char kTag[] = "ar_smv_ctrl"; constexpr std::array kChannelNames{ "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; constexpr std::string_view kTokenDelimiters{" \t\r\n"}; +constexpr std::uint32_t kControlLeaseTimeoutMs = 2500U; SvLiveSignalBank g_signal_bank; std::atomic g_running{false}; std::atomic g_start_request{false}; std::atomic g_publisher_task{nullptr}; +std::atomic g_control_lease_seen{false}; +std::atomic g_last_heartbeat_ms{0U}; void wake_publisher() noexcept { const auto task = g_publisher_task.load(std::memory_order_acquire); @@ -49,6 +53,43 @@ void wake_publisher() noexcept { } } +std::uint32_t monotonic_ms() noexcept { + return static_cast( + static_cast(esp_timer_get_time()) / 1000ULL); +} + +void record_control_heartbeat() noexcept { + g_last_heartbeat_ms.store(monotonic_ms(), std::memory_order_release); + g_control_lease_seen.store(true, std::memory_order_release); +} + +void clear_control_lease() noexcept { + g_control_lease_seen.store(false, std::memory_order_release); + g_last_heartbeat_ms.store(0U, std::memory_order_release); +} + +void enforce_control_lease() noexcept { + if (!g_running.load(std::memory_order_acquire) || + !g_control_lease_seen.load(std::memory_order_acquire)) { + return; + } + + const auto now = monotonic_ms(); + const auto last = g_last_heartbeat_ms.load(std::memory_order_acquire); + const auto elapsed = static_cast(now - last); + if (elapsed <= kControlLeaseTimeoutMs) return; + + bool expected = true; + if (g_running.compare_exchange_strong(expected, false, std::memory_order_acq_rel)) { + g_start_request.store(false, std::memory_order_release); + clear_control_lease(); + wake_publisher(); + ESP_LOGW(kTag, + "Control session lease expired after %lu ms; SV transmission stopped", + static_cast(elapsed)); + } +} + void uppercase_ascii(char* text) noexcept { if (text == nullptr) return; for (; *text != '\0'; ++text) { @@ -123,7 +164,7 @@ void print_identity() noexcept { const esp_app_desc_t* app = esp_app_get_description(); const char* version = app != nullptr ? app->version : "unknown"; ESP_LOGI(kTag, - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s capabilities=SMV-4I4V,LIVE-SETPOINTS", + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE", static_cast(device_id[0]), static_cast(device_id[1]), static_cast(device_id[2]), static_cast(device_id[3]), static_cast(device_id[4]), static_cast(device_id[5]), @@ -279,6 +320,17 @@ void handle_line(char* line) noexcept { if (command == nullptr) return; uppercase_ascii(command); + if (std::strcmp(command, "HEARTBEAT") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: HEARTBEAT"); + } else { + // Intentionally silent: Studio sends this frequently while it owns + // the control session, so it must not create serial/log noise. + record_control_heartbeat(); + } + return; + } + if (std::strcmp(command, "IDENTIFY") == 0) { if (has_extra_token(&save)) ESP_LOGE(kTag, "Usage: IDENTIFY"); else print_identity(); @@ -302,6 +354,7 @@ void handle_line(char* line) noexcept { return; } g_running.store(false, std::memory_order_release); + clear_control_lease(); wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); return; @@ -477,6 +530,7 @@ void live_control_initialize( static_cast(g_signal_bank.publish(initial)); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); + clear_control_lease(); } void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { @@ -486,6 +540,7 @@ void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { void live_control_force_stop() noexcept { g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); + clear_control_lease(); wake_publisher(); } @@ -512,6 +567,8 @@ void live_control_task(void*) noexcept { bool discard_until_eol = false; while (true) { + enforce_control_lease(); + const int input = std::fgetc(stdin); if (input == EOF) { std::clearerr(stdin); From 10a0949205940495a137b1a19ff9f384f8e7efda Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:19:48 +0700 Subject: [PATCH 102/272] fix(studio): refresh session lease immediately before Start --- apps/arstack_studio/src/StudioDeviceController.hpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 42f412b2c..d028ee406 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -96,11 +96,16 @@ class StudioDeviceController : public DeviceController { emit deviceMessage(QStringLiteral("ARStack Studio is still preparing the 4I+4V profile.")); return false; } - ensureSessionHeartbeat(); - if (!sessionHeartbeatTimer_.isActive()) { + + // Refresh the firmware lease synchronously so a STOP -> immediate START + // cannot have a short unprotected RUN window while waiting for the next + // periodic heartbeat tick. + if (!sendQuietCommand(QStringLiteral("HEARTBEAT"))) { emit deviceMessage(QStringLiteral("Control session could not be established before Start.")); return false; } + if (!sessionHeartbeatTimer_.isActive()) sessionHeartbeatTimer_.start(); + if (!flushLiveCommands()) { emit deviceMessage(QStringLiteral("Latest injection values could not be sent before Start.")); return false; From e2222f5f7272e262899f6328193cbfd5e8bbc0e7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:21:15 +0700 Subject: [PATCH 103/272] fix(firmware): activate lease only for fresh Studio sessions --- .../main/live_control.cpp | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 130ba1243..fd048c451 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -43,7 +43,8 @@ SvLiveSignalBank g_signal_bank; std::atomic g_running{false}; std::atomic g_start_request{false}; std::atomic g_publisher_task{nullptr}; -std::atomic g_control_lease_seen{false}; +std::atomic g_control_heartbeat_seen{false}; +std::atomic g_control_lease_active{false}; std::atomic g_last_heartbeat_ms{0U}; void wake_publisher() noexcept { @@ -58,19 +59,38 @@ std::uint32_t monotonic_ms() noexcept { static_cast(esp_timer_get_time()) / 1000ULL); } +bool heartbeat_is_fresh() noexcept { + if (!g_control_heartbeat_seen.load(std::memory_order_acquire)) return false; + const auto now = monotonic_ms(); + const auto last = g_last_heartbeat_ms.load(std::memory_order_acquire); + return static_cast(now - last) <= kControlLeaseTimeoutMs; +} + void record_control_heartbeat() noexcept { g_last_heartbeat_ms.store(monotonic_ms(), std::memory_order_release); - g_control_lease_seen.store(true, std::memory_order_release); + g_control_heartbeat_seen.store(true, std::memory_order_release); + if (g_running.load(std::memory_order_acquire)) { + g_control_lease_active.store(true, std::memory_order_release); + } } -void clear_control_lease() noexcept { - g_control_lease_seen.store(false, std::memory_order_release); +void disarm_control_lease() noexcept { + g_control_lease_active.store(false, std::memory_order_release); +} + +void clear_control_session() noexcept { + g_control_lease_active.store(false, std::memory_order_release); + g_control_heartbeat_seen.store(false, std::memory_order_release); g_last_heartbeat_ms.store(0U, std::memory_order_release); } +void arm_control_lease_if_fresh() noexcept { + g_control_lease_active.store(heartbeat_is_fresh(), std::memory_order_release); +} + void enforce_control_lease() noexcept { if (!g_running.load(std::memory_order_acquire) || - !g_control_lease_seen.load(std::memory_order_acquire)) { + !g_control_lease_active.load(std::memory_order_acquire)) { return; } @@ -82,7 +102,7 @@ void enforce_control_lease() noexcept { bool expected = true; if (g_running.compare_exchange_strong(expected, false, std::memory_order_acq_rel)) { g_start_request.store(false, std::memory_order_release); - clear_control_lease(); + disarm_control_lease(); wake_publisher(); ESP_LOGW(kTag, "Control session lease expired after %lu ms; SV transmission stopped", @@ -342,6 +362,7 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: START"); return; } + arm_control_lease_if_fresh(); g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); wake_publisher(); @@ -354,7 +375,7 @@ void handle_line(char* line) noexcept { return; } g_running.store(false, std::memory_order_release); - clear_control_lease(); + disarm_control_lease(); wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); return; @@ -530,7 +551,7 @@ void live_control_initialize( static_cast(g_signal_bank.publish(initial)); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); - clear_control_lease(); + clear_control_session(); } void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { @@ -540,7 +561,7 @@ void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { void live_control_force_stop() noexcept { g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); - clear_control_lease(); + clear_control_session(); wake_publisher(); } From 6ba953667064d3c5308c6af138a1679c524281e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:25:23 +0700 Subject: [PATCH 104/272] fix(firmware): enforce control lease independently of console input --- .../main/live_control.cpp | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index fd048c451..6441729fb 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -38,6 +38,8 @@ constexpr std::array kChannelNames{ "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; constexpr std::string_view kTokenDelimiters{" \t\r\n"}; constexpr std::uint32_t kControlLeaseTimeoutMs = 2500U; +constexpr std::uint64_t kControlLeaseTimeoutUs = + static_cast(kControlLeaseTimeoutMs) * 1000ULL; SvLiveSignalBank g_signal_bank; std::atomic g_running{false}; @@ -46,6 +48,7 @@ std::atomic g_publisher_task{nullptr}; std::atomic g_control_heartbeat_seen{false}; std::atomic g_control_lease_active{false}; std::atomic g_last_heartbeat_ms{0U}; +esp_timer_handle_t g_control_lease_timer{nullptr}; void wake_publisher() noexcept { const auto task = g_publisher_task.load(std::memory_order_acquire); @@ -66,28 +69,19 @@ bool heartbeat_is_fresh() noexcept { return static_cast(now - last) <= kControlLeaseTimeoutMs; } -void record_control_heartbeat() noexcept { - g_last_heartbeat_ms.store(monotonic_ms(), std::memory_order_release); - g_control_heartbeat_seen.store(true, std::memory_order_release); - if (g_running.load(std::memory_order_acquire)) { - g_control_lease_active.store(true, std::memory_order_release); - } -} - void disarm_control_lease() noexcept { g_control_lease_active.store(false, std::memory_order_release); + if (g_control_lease_timer != nullptr) { + static_cast(esp_timer_stop(g_control_lease_timer)); + } } void clear_control_session() noexcept { - g_control_lease_active.store(false, std::memory_order_release); + disarm_control_lease(); g_control_heartbeat_seen.store(false, std::memory_order_release); g_last_heartbeat_ms.store(0U, std::memory_order_release); } -void arm_control_lease_if_fresh() noexcept { - g_control_lease_active.store(heartbeat_is_fresh(), std::memory_order_release); -} - void enforce_control_lease() noexcept { if (!g_running.load(std::memory_order_acquire) || !g_control_lease_active.load(std::memory_order_acquire)) { @@ -102,7 +96,7 @@ void enforce_control_lease() noexcept { bool expected = true; if (g_running.compare_exchange_strong(expected, false, std::memory_order_acq_rel)) { g_start_request.store(false, std::memory_order_release); - disarm_control_lease(); + g_control_lease_active.store(false, std::memory_order_release); wake_publisher(); ESP_LOGW(kTag, "Control session lease expired after %lu ms; SV transmission stopped", @@ -110,6 +104,34 @@ void enforce_control_lease() noexcept { } } +void control_lease_timer_callback(void*) noexcept { + enforce_control_lease(); +} + +void rearm_control_lease_timer() noexcept { + if (g_control_lease_timer == nullptr) return; + static_cast(esp_timer_stop(g_control_lease_timer)); + const auto result = esp_timer_start_once(g_control_lease_timer, kControlLeaseTimeoutUs); + if (result != ESP_OK) { + ESP_LOGE(kTag, "Unable to arm control-session lease timer: %d", static_cast(result)); + } +} + +void record_control_heartbeat() noexcept { + g_last_heartbeat_ms.store(monotonic_ms(), std::memory_order_release); + g_control_heartbeat_seen.store(true, std::memory_order_release); + if (g_running.load(std::memory_order_acquire)) { + g_control_lease_active.store(true, std::memory_order_release); + rearm_control_lease_timer(); + } +} + +void arm_control_lease_if_fresh() noexcept { + const bool active = heartbeat_is_fresh(); + g_control_lease_active.store(active, std::memory_order_release); + if (active) rearm_control_lease_timer(); +} + void uppercase_ascii(char* text) noexcept { if (text == nullptr) return; for (; *text != '\0'; ++text) { @@ -552,6 +574,17 @@ void live_control_initialize( g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); clear_control_session(); + + if (g_control_lease_timer == nullptr) { + esp_timer_create_args_t timer_args{}; + timer_args.callback = &control_lease_timer_callback; + timer_args.name = "ar_ctrl_lease"; + const auto result = esp_timer_create(&timer_args, &g_control_lease_timer); + if (result != ESP_OK) { + g_control_lease_timer = nullptr; + ESP_LOGE(kTag, "Unable to create control-session lease timer: %d", static_cast(result)); + } + } } void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { @@ -588,8 +621,6 @@ void live_control_task(void*) noexcept { bool discard_until_eol = false; while (true) { - enforce_control_lease(); - const int input = std::fgetc(stdin); if (input == EOF) { std::clearerr(stdin); From 0ac6e85686da88da1f0f974fa72fd29d816b4da0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:27:25 +0700 Subject: [PATCH 105/272] fix(firmware): make session lease fail closed at timer boundary --- .../main/live_control.cpp | 86 +++++++++++++------ 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 6441729fb..a2d3fe2e8 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -69,6 +69,17 @@ bool heartbeat_is_fresh() noexcept { return static_cast(now - last) <= kControlLeaseTimeoutMs; } +bool start_control_lease_timer(const std::uint64_t delay_us) noexcept { + if (g_control_lease_timer == nullptr) return false; + static_cast(esp_timer_stop(g_control_lease_timer)); + const auto result = esp_timer_start_once(g_control_lease_timer, delay_us == 0U ? 1U : delay_us); + if (result != ESP_OK) { + ESP_LOGE(kTag, "Unable to arm control-session lease timer: %d", static_cast(result)); + return false; + } + return true; +} + void disarm_control_lease() noexcept { g_control_lease_active.store(false, std::memory_order_release); if (g_control_lease_timer != nullptr) { @@ -82,6 +93,16 @@ void clear_control_session() noexcept { g_last_heartbeat_ms.store(0U, std::memory_order_release); } +void stop_for_lease_failure(const char* reason) noexcept { + bool expected = true; + if (g_running.compare_exchange_strong(expected, false, std::memory_order_acq_rel)) { + g_start_request.store(false, std::memory_order_release); + g_control_lease_active.store(false, std::memory_order_release); + wake_publisher(); + ESP_LOGW(kTag, "%s; SV transmission stopped", reason); + } +} + void enforce_control_lease() noexcept { if (!g_running.load(std::memory_order_acquire) || !g_control_lease_active.load(std::memory_order_acquire)) { @@ -91,12 +112,19 @@ void enforce_control_lease() noexcept { const auto now = monotonic_ms(); const auto last = g_last_heartbeat_ms.load(std::memory_order_acquire); const auto elapsed = static_cast(now - last); - if (elapsed <= kControlLeaseTimeoutMs) return; + if (elapsed < kControlLeaseTimeoutMs) { + const auto remaining_us = + static_cast(kControlLeaseTimeoutMs - elapsed) * 1000ULL; + if (!start_control_lease_timer(remaining_us)) { + stop_for_lease_failure("Control session lease timer failed"); + } + return; + } - bool expected = true; - if (g_running.compare_exchange_strong(expected, false, std::memory_order_acq_rel)) { - g_start_request.store(false, std::memory_order_release); - g_control_lease_active.store(false, std::memory_order_release); + g_control_lease_active.store(false, std::memory_order_release); + g_start_request.store(false, std::memory_order_release); + const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); + if (was_running) { wake_publisher(); ESP_LOGW(kTag, "Control session lease expired after %lu ms; SV transmission stopped", @@ -108,28 +136,31 @@ void control_lease_timer_callback(void*) noexcept { enforce_control_lease(); } -void rearm_control_lease_timer() noexcept { - if (g_control_lease_timer == nullptr) return; - static_cast(esp_timer_stop(g_control_lease_timer)); - const auto result = esp_timer_start_once(g_control_lease_timer, kControlLeaseTimeoutUs); - if (result != ESP_OK) { - ESP_LOGE(kTag, "Unable to arm control-session lease timer: %d", static_cast(result)); - } -} - void record_control_heartbeat() noexcept { g_last_heartbeat_ms.store(monotonic_ms(), std::memory_order_release); g_control_heartbeat_seen.store(true, std::memory_order_release); - if (g_running.load(std::memory_order_acquire)) { - g_control_lease_active.store(true, std::memory_order_release); - rearm_control_lease_timer(); + if (!g_running.load(std::memory_order_acquire)) return; + + if (!start_control_lease_timer(kControlLeaseTimeoutUs)) { + stop_for_lease_failure("Control session heartbeat could not rearm the lease timer"); + return; } + g_control_lease_active.store(true, std::memory_order_release); } -void arm_control_lease_if_fresh() noexcept { - const bool active = heartbeat_is_fresh(); - g_control_lease_active.store(active, std::memory_order_release); - if (active) rearm_control_lease_timer(); +bool arm_control_lease_if_fresh() noexcept { + if (!heartbeat_is_fresh()) { + // No current Studio heartbeat: preserve the legacy bench/manual START + // behavior. Only a Studio-owned session is required to carry a lease. + g_control_lease_active.store(false, std::memory_order_release); + return true; + } + if (!start_control_lease_timer(kControlLeaseTimeoutUs)) { + g_control_lease_active.store(false, std::memory_order_release); + return false; + } + g_control_lease_active.store(true, std::memory_order_release); + return true; } void uppercase_ascii(char* text) noexcept { @@ -205,12 +236,16 @@ void print_identity() noexcept { } const esp_app_desc_t* app = esp_app_get_description(); const char* version = app != nullptr ? app->version : "unknown"; + const char* capabilities = g_control_lease_timer != nullptr + ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE" + : "SMV-4I4V,LIVE-SETPOINTS"; ESP_LOGI(kTag, - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE", + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s capabilities=%s", static_cast(device_id[0]), static_cast(device_id[1]), static_cast(device_id[2]), static_cast(device_id[3]), static_cast(device_id[4]), static_cast(device_id[5]), - version); + version, + capabilities); } void print_ptp_state() noexcept { @@ -384,7 +419,10 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: START"); return; } - arm_control_lease_if_fresh(); + if (!arm_control_lease_if_fresh()) { + ESP_LOGE(kTag, "START rejected: Studio control-session lease is unavailable"); + return; + } g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); wake_publisher(); From fa937b0d0e6786c2dc56110b006303cafc1aed0e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 17:29:26 +0700 Subject: [PATCH 106/272] docs(studio): record checkpoint G session lease --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 36 ++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index 3103f56c5..e71faac1f 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -34,8 +34,8 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Native queue capacity is bounded by design to one pending frequency plus the eight fixed 4I+4V channel IDs; it cannot grow with repeated typing. - ✅ Native live writes flush on a short timer, and START forces the newest queued values to the device before enabling output. - ✅ Disconnect/unverify clears pending live writes; ZERO cancels pending channel writes so an old queued value cannot reappear after zeroing. -- ✅ Numeric controls now use a stronger visual hierarchy (larger value text, clearer focus state, larger suffixes) because injection values are the primary operator task. -- ✅ Current/voltage matrices use larger channel labels, roomier rows, simpler `Channel / Value / Phase` headings, and clearer operator-facing validation messages. +- ✅ Numeric controls use a stronger visual hierarchy because injection values are the primary operator task. +- ✅ Current/voltage matrices use larger channel labels, roomier rows, simple `Channel / Value / Phase` headings, and clearer validation messages. - 🟡 Native live-write coalescing still needs hardware feel/latency acceptance during rapid edits. ## Firmware intelligence @@ -44,7 +44,8 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Recovery can probe and flash ESP32-P4 without ESP-IDF/Python on the operator PC. - ✅ ROM/download-mode recovery has guided BOOT/RESET fallback. - ✅ ESP-IDF project version is pinned to `0.1.0` for deterministic semantic firmware identity. -- ✅ New firmware `IDENTIFY` appends `firmware=` and `capabilities=SMV-4I4V,LIVE-SETPOINTS` while preserving the older identity prefix for backwards compatibility. +- ✅ New firmware `IDENTIFY` appends `firmware=` while preserving the older identity prefix for backwards compatibility. +- ✅ Firmware capabilities now advertise `SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE` when the independent lease timer is available. - ✅ Smart session distinguishes current firmware from legacy/outdated firmware. Missing semantic version is treated as legacy rather than silently accepted. - ✅ Friendly firmware-update prompt is implemented with concise operator wording. - 🟡 One-click update coordinator is implemented: safe STOP if required -> release serial -> ROM probe -> verified bundled flash -> reset -> reconnect -> verify semantic identity -> prepare 4I+4V. @@ -67,9 +68,9 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Firmware update failure surfaces a concise operator message instead of silently dropping back to an idle state. - ✅ Advanced configuration header/typography is simplified and enlarged; tabs are `Injection / Firmware / Waveform / Timing / Device` with one `Device ready / Setup mode` status pill. - ✅ Telemetry is collapsed by default and reduced to one quiet status line; expanded mode contains only `Recent activity` and `Transmission` instead of duplicating channel state. -- ✅ Main action ribbon now uses human-readable state labels instead of raw state-machine names and keeps Ready/Running copy intentionally short. -- ✅ Injection value fields and current/voltage matrices were visually promoted above chrome/status elements. -- ⬜ Main workspace title and the remaining legacy top-level header/footer language still need simplification in `Main.qml`. +- ✅ Main action ribbon uses human-readable state labels instead of raw state-machine names and keeps Ready/Running copy intentionally short. +- ✅ Injection value fields and current/voltage matrices are visually promoted above chrome/status elements. +- ⬜ Main workspace title and remaining legacy top-level header/footer language still need simplification in `Main.qml`. - ⬜ Remove the remaining duplicate top-level device indicator and keyboard-help noise from `Main.qml` normal view. - ⬜ Review default window density at 100%, 125%, 150%, and laptop resolutions after a packaged build is available. @@ -80,23 +81,28 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Profile deployment remains stopped-only and fail-closed. - ✅ Firmware package/target/hash checks remain fail-closed. - ✅ Startup discovery race was removed; initial probe and hot-plug watchdog are serialized by state. -- ✅ Smart-session orchestration moved into one native C++ state machine (`WAITING FOR DEVICE / DEVICE FOUND / CONNECTING / FIRMWARE UPDATE / UPDATING FIRMWARE / UPDATE NEEDS BOOT / PREPARING 4I+4V / READY / RUNNING / ERROR`). -- ✅ Reconnect preparation is delayed briefly after identity verification so existing SHOW / PROFILE SHOW responses can settle before automatic profile synchronization. +- ✅ Smart-session orchestration is centralized in native C++. - ✅ Firmware update completion is not trusted until the newly reconnected firmware reports the expected semantic version. - ✅ Firmware update state exits deterministically on probe rejection/failure instead of hanging indefinitely. - ✅ Post-flash reconnect is bounded to six retry windows and waits for active discovery/verification instead of launching overlapping probes. - ✅ Live-edit write backlog is bounded and last-value-wins instead of growing with operator keystrokes. - ✅ Normal application shutdown performs a best-effort STOP before the control process exits while output is RUNNING. +- 🟡 Studio now maintains a silent 700 ms control heartbeat only for current semantic firmware; legacy firmware is not spammed with an unknown command. +- 🟡 Studio sends a fresh heartbeat synchronously before START; a Studio-owned START fails closed if that control-session write cannot be established. +- 🟡 Firmware implements a 2.5 s session lease using an independent one-shot `esp_timer`, so lease expiry does not depend on serial input continuing after a GUI crash. +- 🟡 Lease timeout handles millisecond-boundary rounding by rearming the remaining interval rather than silently losing the one-shot. +- 🟡 If the firmware lease timer cannot be created/rearmed, a fresh Studio-owned START is rejected or an already-running Studio session is fail-stopped. +- ✅ Manual/bench START remains backwards compatible: without a fresh Studio heartbeat, the firmware does not force a session lease. - 🟡 General hot-plug discovery remains periodic and lightweight; hardware acceptance is still needed for repeated unplug/replug cycles. -- ⬜ Add a firmware session lease/watchdog so a hard GUI crash or control-link loss cannot leave an operator-owned RUN session transmitting indefinitely. -- ⬜ Add regression tests for USB removal during READY and RUNNING. -- ⬜ Add regression tests for malformed/slow serial responses and command timeouts. -- ⬜ Add soak test for continuous 4000 fps operation plus repeated live edits. +- ⬜ Hardware kill test: terminate Studio while RUNNING and verify external SMV capture stops within approximately 2.5–3 s. +- ⬜ Hardware USB-removal tests during READY and RUNNING. +- ⬜ Regression tests for malformed/slow serial responses and command timeouts. +- ⬜ Soak test for continuous 4000 fps operation plus repeated live edits. ## Current checkpoint -Checkpoint F — **zero-configuration workflow + semantic firmware update + bounded live writes/reconnect + numeric firmware progress + operator-first visual hierarchy + graceful STOP on normal exit** is implemented in the branch. +Checkpoint G — **zero-configuration workflow + semantic firmware update + bounded live writes/reconnect + numeric firmware progress + operator-first visual hierarchy + Studio/firmware control-session lease** is implemented in the branch. -Current validation state: **CI and hardware acceptance pending for the newest Smart UX head.** Do not treat this checkpoint as release-ready until those gates pass. +Current validation state: **latest-head CI and hardware acceptance are pending.** Do not describe the session lease or this checkpoint as hardware-proven until the ESP32 build passes and the hard-kill/USB tests are completed. -Next implementation checkpoint after CI: remaining top-level `Main.qml` chrome cleanup and failure-path regression coverage. A firmware session lease/watchdog is still required before claiming hard-crash-safe RUN behavior, followed by hardware acceptance of both current-firmware and legacy-firmware paths. +Next checkpoint after CI: finish the remaining `Main.qml` top-level chrome cleanup without disturbing injection logic, build a new Windows RC, then run current-firmware, legacy-update, hard-kill, USB-replug, and sustained 4000 fps hardware acceptance. From be417eb839cad4372887ddafc6c699ddaf98647a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:04:57 +0700 Subject: [PATCH 107/272] perf(studio): make firmware process lifecycle asynchronous and bounded --- apps/arstack_studio/src/FirmwareManager.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 8d97d38ea..ca9a053d9 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -4,6 +4,7 @@ #include #include #include +#include class FirmwareManager : public QObject { Q_OBJECT @@ -68,12 +69,15 @@ class FirmwareManager : public QObject { bool loadManifest(); bool startEspflash(const QStringList& arguments, Operation operation); void finishOperation(int exitCode, QProcess::ExitStatus exitStatus); + void handleProcessError(QProcess::ProcessError error); void updateProgressFromOutput(const QString& text); + void appendOperationOutput(const QString& text); void appendLog(const QString& text); void setStatus(const QString& text); void fail(const QString& text); QProcess process_; + QTimer startupTimer_; Operation operation_{Operation::none}; QString operationOutput_; QString selectedPort_; @@ -92,4 +96,5 @@ class FirmwareManager : public QObject { bool busy_{false}; bool targetVerified_{false}; bool bootloaderHelpNeeded_{false}; + bool cancelRequested_{false}; }; From 6b66f55572c5db324b2b6a1e3fd2fc20b05a9106 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:05:53 +0700 Subject: [PATCH 108/272] perf(studio): remove blocking waits from firmware flashing --- apps/arstack_studio/src/FirmwareManager.cpp | 105 +++++++++++++++----- 1 file changed, 82 insertions(+), 23 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index dba611708..4bef77dc0 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -20,6 +20,8 @@ constexpr auto kManifestName = "firmware-manifest.json"; constexpr auto kManifestSchema = "arstack.studio.firmware.v1"; constexpr auto kExpectedChip = "esp32p4"; constexpr auto kPreV3Policy = "pre-v3"; +constexpr qsizetype kMaxOperationOutput = 65536; +constexpr qsizetype kTrimmedOperationOutput = 49152; QString normalizedHash(const QString& text) { QString hash = text.trimmed().toLower(); @@ -30,15 +32,36 @@ QString normalizedHash(const QString& text) { FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { process_.setProcessChannelMode(QProcess::SeparateChannels); + + startupTimer_.setSingleShot(true); + startupTimer_.setInterval(2500); + connect(&startupTimer_, &QTimer::timeout, this, [this] { + if (!busy_ || operation_ == Operation::none || process_.state() == QProcess::NotRunning) return; + + const Operation failedOperation = operation_; + operation_ = Operation::none; + busy_ = false; + flashProgress_ = -1; + process_.kill(); + fail(QStringLiteral("Firmware tool did not start within 2.5 seconds.")); + if (failedOperation == Operation::probe) bootloaderHelpNeeded_ = false; + emit stateChanged(); + }); + + connect(&process_, &QProcess::started, this, [this] { + startupTimer_.stop(); + }); + connect(&process_, &QProcess::errorOccurred, + this, &FirmwareManager::handleProcessError); connect(&process_, &QProcess::readyReadStandardOutput, this, [this] { const QString text = QString::fromUtf8(process_.readAllStandardOutput()); - operationOutput_ += text; + appendOperationOutput(text); updateProgressFromOutput(text); appendLog(text); }); connect(&process_, &QProcess::readyReadStandardError, this, [this] { const QString text = QString::fromUtf8(process_.readAllStandardError()); - operationOutput_ += text; + appendOperationOutput(text); updateProgressFromOutput(text); appendLog(text); }); @@ -179,7 +202,7 @@ bool FirmwareManager::loadManifest() { bool FirmwareManager::probeTarget(const QString& portName) { if (busy_) return false; - refreshBundle(); + if (!bundleReady_ || !flasherAvailable_) refreshBundle(); if (!flasherAvailable_) { fail(QStringLiteral("Bundled espflash executable is unavailable.")); return false; @@ -196,6 +219,7 @@ bool FirmwareManager::probeTarget(const QString& portName) { flashProgress_ = -1; targetChip_ = QStringLiteral("Checking %1...").arg(port); busy_ = true; + cancelRequested_ = false; setStatus(QStringLiteral("Checking ESP32-P4 ROM identity on %1...").arg(port)); emit stateChanged(); return startEspflash({ @@ -205,7 +229,7 @@ bool FirmwareManager::probeTarget(const QString& portName) { bool FirmwareManager::installFirmware(const QString& portName) { if (busy_) return false; - refreshBundle(); + if (!bundleReady_ || !flasherAvailable_) refreshBundle(); const QString port = portName.trimmed(); if (!bundleReady_) { fail(bundleStatus_); @@ -219,6 +243,7 @@ bool FirmwareManager::installFirmware(const QString& portName) { bootloaderHelpNeeded_ = false; flashProgress_ = 0; busy_ = true; + cancelRequested_ = false; setStatus(QStringLiteral("Installing ARStack firmware v%1 on %2...").arg(firmwareVersion_, port)); emit stateChanged(); return startEspflash({ @@ -232,13 +257,21 @@ bool FirmwareManager::installFirmware(const QString& portName) { void FirmwareManager::cancel() { if (!busy_) return; - process_.kill(); - process_.waitForFinished(1200); - operation_ = Operation::none; - busy_ = false; - flashProgress_ = -1; - setStatus(QStringLiteral("Firmware operation cancelled.")); + cancelRequested_ = true; + startupTimer_.stop(); + setStatus(QStringLiteral("Cancelling firmware operation...")); emit stateChanged(); + + if (process_.state() == QProcess::NotRunning) { + cancelRequested_ = false; + operation_ = Operation::none; + busy_ = false; + flashProgress_ = -1; + setStatus(QStringLiteral("Firmware operation cancelled.")); + emit stateChanged(); + return; + } + process_.kill(); } void FirmwareManager::clearLog() { @@ -263,22 +296,40 @@ bool FirmwareManager::startEspflash(const QStringList& arguments, const Operatio process_.setArguments(arguments); appendLog(QStringLiteral("$ ESPFLASH_PORT=%1 espflash %2\n") .arg(selectedPort_, arguments.join(QLatin1Char(' ')))); + process_.start(); - if (!process_.waitForStarted(2500)) { - fail(QStringLiteral("Unable to start bundled espflash.")); - operation_ = Operation::none; - busy_ = false; - flashProgress_ = -1; - emit stateChanged(); - return false; - } + startupTimer_.start(); return true; } +void FirmwareManager::handleProcessError(const QProcess::ProcessError error) { + if (error != QProcess::FailedToStart || operation_ == Operation::none) return; + + startupTimer_.stop(); + operation_ = Operation::none; + busy_ = false; + flashProgress_ = -1; + cancelRequested_ = false; + fail(QStringLiteral("Unable to start bundled espflash: %1").arg(process_.errorString())); + emit stateChanged(); +} + void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitStatus exitStatus) { - const bool success = exitStatus == QProcess::NormalExit && exitCode == 0; + startupTimer_.stop(); const Operation completed = operation_; operation_ = Operation::none; + if (completed == Operation::none) return; + + if (cancelRequested_) { + cancelRequested_ = false; + busy_ = false; + flashProgress_ = -1; + setStatus(QStringLiteral("Firmware operation cancelled.")); + emit stateChanged(); + return; + } + + const bool success = exitStatus == QProcess::NormalExit && exitCode == 0; if (completed == Operation::probe) { busy_ = false; @@ -310,7 +361,7 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt setStatus(QStringLiteral("A chip answered on %1, but it is not an ESP32-P4. Installation is blocked.") .arg(selectedPort_)); } else { - setStatus(QStringLiteral("%1 is visible in Windows, but the ESP32-P4 ROM did not answer. Put the board in Download mode, then click Check board again.") + setStatus(QStringLiteral("%1 is visible in Windows, but the ESP32-P4 ROM did not answer. Put the board in Download mode, then retry.") .arg(selectedPort_)); } emit stateChanged(); @@ -322,7 +373,7 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt busy_ = false; flashProgress_ = -1; bootloaderHelpNeeded_ = true; - fail(QStringLiteral("Firmware flash failed. Keep the board in Download mode and retry. Device contents were not trusted.")); + fail(QStringLiteral("Firmware installation failed. Put the board in Download mode and retry. The device is not considered ready.")); emit stateChanged(); return; } @@ -348,10 +399,10 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt targetVerified_ = false; bootloaderHelpNeeded_ = false; targetChip_ = QStringLiteral("Awaiting ARStack firmware"); - setStatus(QStringLiteral("Firmware installed. The board was reset; Studio is reconnecting and verifying ARStack identity.")); + setStatus(QStringLiteral("Firmware written successfully. Studio is reconnecting and verifying the board.")); } else { bootloaderHelpNeeded_ = false; - setStatus(QStringLiteral("Flash succeeded, but automatic reset failed. Press RESET once or reconnect USB; Studio will then verify the board.")); + setStatus(QStringLiteral("Firmware was written, but automatic reset failed. Press RESET once or reconnect USB; Studio will verify the board.")); } emit stateChanged(); emit installationFinished(success); @@ -366,6 +417,14 @@ void FirmwareManager::updateProgressFromOutput(const QString& text) { emit stateChanged(); } +void FirmwareManager::appendOperationOutput(const QString& text) { + if (text.isEmpty()) return; + operationOutput_ += text; + if (operationOutput_.size() > kMaxOperationOutput) { + operationOutput_ = operationOutput_.right(kTrimmedOperationOutput); + } +} + void FirmwareManager::appendLog(const QString& text) { if (text.isEmpty()) return; const QString stamped = QStringLiteral("[%1] %2") From de7cf0e9e2a7551de32ce6a3cfa49bc32806e6c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:06:29 +0700 Subject: [PATCH 109/272] feat(studio): model blank ESP32-P4 onboarding in smart session --- .../arstack_studio/src/SmartSessionController.hpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 6fd9b5d29..8b5414306 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -17,9 +17,12 @@ class SmartSessionController : public QObject { Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) + Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallRequired NOTIFY stateChanged) Q_PROPERTY(bool updatingFirmware READ updatingFirmware NOTIFY stateChanged) Q_PROPERTY(bool updateNeedsBootloaderHelp READ updateNeedsBootloaderHelp NOTIFY stateChanged) + Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged) Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged) + Q_PROPERTY(QString firmwareSetupPort READ firmwareSetupPort NOTIFY stateChanged) Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT) Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged) @@ -33,9 +36,12 @@ class SmartSessionController : public QObject { [[nodiscard]] QString statusText() const; [[nodiscard]] bool startReady() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; + [[nodiscard]] bool firmwareInstallRequired() const noexcept; [[nodiscard]] bool updatingFirmware() const noexcept; [[nodiscard]] bool updateNeedsBootloaderHelp() const noexcept; + [[nodiscard]] int firmwareProgress() const noexcept; [[nodiscard]] QString updateStatus() const; + [[nodiscard]] QString firmwareSetupPort() const; [[nodiscard]] QString expectedFirmwareVersion() const; [[nodiscard]] QString deviceFirmwareVersion() const; @@ -46,6 +52,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE void start(); Q_INVOKABLE void reconcile(); Q_INVOKABLE bool beginFirmwareUpdate(); + Q_INVOKABLE bool beginFirmwareInstall(); Q_INVOKABLE bool retryFirmwareUpdate(); signals: @@ -60,6 +67,9 @@ class SmartSessionController : public QObject { void reconnectDeviceSignals(); void reconnectProfileSignals(); void reconnectFirmwareSignals(); + void maybeScheduleBlankBoardProbe(); + void clearBlankBoardContext(); + bool beginFirmwareOperation(const QString& portName); bool ensureDefaultProfile(); void setPresentation(QString state, QString status, bool startReady, bool firmwareUpdateRequired); void refreshFirmwareIdentity(); @@ -72,10 +82,13 @@ class SmartSessionController : public QObject { QTimer discoveryTimer_; QTimer prepareTimer_; QTimer reconnectTimer_; + QTimer blankProbeTimer_; QString state_{QStringLiteral("WAITING FOR DEVICE")}; QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; QString deviceFirmwareVersion_; QString updatePort_; + QString blankBoardPort_; + QString blankProbeAttemptedPort_; bool startReady_{false}; bool firmwareUpdateRequired_{false}; bool started_{false}; @@ -83,6 +96,8 @@ class SmartSessionController : public QObject { bool needsProfileSync_{true}; bool profileSyncInFlight_{false}; bool updateRequested_{false}; + bool blankProbeInFlight_{false}; + bool blankBoardDetected_{false}; int updateReconnectAttempts_{0}; UpdateStage updateStage_{UpdateStage::idle}; }; From 5b161e2b263783af4f78a945ff42ce45fd45045f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:07:39 +0700 Subject: [PATCH 110/272] feat(studio): detect unconfigured ESP32-P4 and offer firmware install --- .../src/SmartSessionController.cpp | 172 ++++++++++++++++-- 1 file changed, 153 insertions(+), 19 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 1f3180bc7..060e6b8d7 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -24,7 +24,8 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent discoveryTimer_.setInterval(2500); discoveryTimer_.setSingleShot(false); connect(&discoveryTimer_, &QTimer::timeout, this, [this] { - if (!started_ || device_ == nullptr || updateRequested_) return; + if (!started_ || device_ == nullptr || updateRequested_ || blankProbeInFlight_ || blankBoardDetected_) return; + if (firmware_ != nullptr && firmware_->busy()) return; if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { static_cast(device_->autoDetectAndConnect()); } @@ -34,6 +35,33 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent prepareTimer_.setSingleShot(true); connect(&prepareTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); + blankProbeTimer_.setInterval(450); + blankProbeTimer_.setSingleShot(true); + connect(&blankProbeTimer_, &QTimer::timeout, this, [this] { + if (!started_ || device_ == nullptr || firmware_ == nullptr || updateRequested_ || + device_->deviceVerified() || device_->discovering() || device_->connected() || + firmware_->busy() || !firmware_->bundleReady() || blankBoardDetected_ || blankProbeInFlight_) { + return; + } + + const QString port = device_->recommendedPort().trimmed(); + if (port.isEmpty() || port == blankProbeAttemptedPort_) return; + + blankProbeAttemptedPort_ = port; + blankBoardPort_ = port; + blankProbeInFlight_ = true; + setPresentation( + QStringLiteral("CHECKING DEVICE"), + QStringLiteral("Checking whether the connected ESP32-P4 needs ARStack firmware…"), + false, + false); + + if (!firmware_->probeTarget(port)) { + blankProbeInFlight_ = false; + reconcile(); + } + }); + reconnectTimer_.setInterval(3000); reconnectTimer_.setSingleShot(true); connect(&reconnectTimer_, &QTimer::timeout, this, [this] { @@ -67,6 +95,9 @@ QString SmartSessionController::state() const { return state_; } QString SmartSessionController::statusText() const { return statusText_; } bool SmartSessionController::startReady() const noexcept { return startReady_; } bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } +bool SmartSessionController::firmwareInstallRequired() const noexcept { + return blankBoardDetected_ && !updateRequested_; +} bool SmartSessionController::updatingFirmware() const noexcept { return updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::probing || updateStage_ == UpdateStage::flashing || updateStage_ == UpdateStage::reconnecting; @@ -74,6 +105,10 @@ bool SmartSessionController::updatingFirmware() const noexcept { bool SmartSessionController::updateNeedsBootloaderHelp() const noexcept { return updateStage_ == UpdateStage::waitingForBootloader; } +int SmartSessionController::firmwareProgress() const noexcept { + if (firmware_ == nullptr || updateStage_ != UpdateStage::flashing) return -1; + return firmware_->flashProgress(); +} QString SmartSessionController::updateStatus() const { if (firmware_ == nullptr) return {}; switch (updateStage_) { @@ -83,16 +118,17 @@ QString SmartSessionController::updateStatus() const { case UpdateStage::flashing: return firmware_->status(); case UpdateStage::reconnecting: - return QStringLiteral("Firmware installed. Reconnecting to ESP32-P4… %1/%2") + return QStringLiteral("Firmware written. Reconnecting and verifying the board… %1/%2") .arg(std::min(updateReconnectAttempts_ + 1, kMaxUpdateReconnectAttempts)) .arg(kMaxUpdateReconnectAttempts); case UpdateStage::waitingForBootloader: - return QStringLiteral("Hold BOOT, press and release RESET, release BOOT, then Retry update."); + return QStringLiteral("Put the board in Download mode: hold BOOT, press and release RESET, then release BOOT."); case UpdateStage::idle: return {}; } return {}; } +QString SmartSessionController::firmwareSetupPort() const { return blankBoardPort_; } QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); } QString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; } @@ -104,6 +140,7 @@ void SmartSessionController::setDevice(QObject* object) { deviceFirmwareVersion_.clear(); needsProfileSync_ = true; profileSyncInFlight_ = false; + clearBlankBoardContext(); reconnectDeviceSignals(); emit dependenciesChanged(); reconcile(); @@ -126,6 +163,7 @@ void SmartSessionController::setFirmware(QObject* object) { if (firmware_ == next) return; if (firmware_ != nullptr) disconnect(firmware_, nullptr, this, nullptr); firmware_ = next; + clearBlankBoardContext(); reconnectFirmwareSignals(); emit dependenciesChanged(); reconcile(); @@ -137,6 +175,7 @@ void SmartSessionController::start() { if (profiles_ != nullptr) static_cast(ensureDefaultProfile()); discoveryTimer_.start(); reconcile(); + maybeScheduleBlankBoardProbe(); } bool SmartSessionController::beginFirmwareUpdate() { @@ -144,20 +183,34 @@ bool SmartSessionController::beginFirmwareUpdate() { !firmware_->bundleReady() || firmware_->busy()) { return false; } + return beginFirmwareOperation(device_->portName()); +} + +bool SmartSessionController::beginFirmwareInstall() { + if (device_ == nullptr || firmware_ == nullptr || !blankBoardDetected_ || + !firmware_->bundleReady() || firmware_->busy() || blankBoardPort_.isEmpty()) { + return false; + } + return beginFirmwareOperation(blankBoardPort_); +} - updatePort_ = device_->portName().trimmed(); - if (updatePort_.isEmpty()) return false; +bool SmartSessionController::beginFirmwareOperation(const QString& portName) { + const QString port = portName.trimmed(); + if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || firmware_->busy()) return false; + updatePort_ = port; updateRequested_ = true; updateReconnectAttempts_ = 0; needsProfileSync_ = true; profileSyncInFlight_ = false; + blankProbeTimer_.stop(); + blankProbeInFlight_ = false; if (device_->running()) { updateStage_ = UpdateStage::stopping; setPresentation( QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Stopping SMV output before firmware update…"), + QStringLiteral("Stopping SMV output before firmware installation…"), false, false); if (!device_->stop()) { @@ -181,11 +234,13 @@ bool SmartSessionController::retryFirmwareUpdate() { if (device_ != nullptr && device_->connected()) device_->disconnectPort(); setPresentation( QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Checking ESP32-P4 bootloader…"), + QStringLiteral("Checking ESP32-P4 Download mode…"), false, false); if (!firmware_->probeTarget(updatePort_)) { - updateStage_ = UpdateStage::waitingForBootloader; + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); reconcile(); return false; } @@ -203,11 +258,13 @@ void SmartSessionController::continueFirmwareUpdate() { if (device_->connected()) device_->disconnectPort(); setPresentation( QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Checking ESP32-P4 and preparing firmware…"), + QStringLiteral("Verifying ESP32-P4 before firmware installation…"), false, false); if (!firmware_->probeTarget(updatePort_)) { - updateStage_ = UpdateStage::waitingForBootloader; + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); reconcile(); } } @@ -217,6 +274,7 @@ void SmartSessionController::reconnectDeviceSignals() { connect(device_, &DeviceController::deviceVerifiedChanged, this, [this] { if (device_ != nullptr && device_->deviceVerified()) { + clearBlankBoardContext(); refreshFirmwareIdentity(); needsProfileSync_ = true; profileSyncInFlight_ = false; @@ -228,9 +286,24 @@ void SmartSessionController::reconnectDeviceSignals() { } reconcile(); }); - connect(device_, &DeviceController::connectedChanged, this, &SmartSessionController::reconcile); - connect(device_, &DeviceController::discoveryChanged, this, &SmartSessionController::reconcile); - connect(device_, &DeviceController::portsChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::connectedChanged, this, [this] { + reconcile(); + maybeScheduleBlankBoardProbe(); + }); + connect(device_, &DeviceController::discoveryChanged, this, [this] { + reconcile(); + maybeScheduleBlankBoardProbe(); + }); + connect(device_, &DeviceController::portsChanged, this, [this] { + if (device_ == nullptr) return; + if (!blankBoardPort_.isEmpty() && !device_->ports().contains(blankBoardPort_)) { + clearBlankBoardContext(); + } else if (!blankProbeAttemptedPort_.isEmpty() && !device_->ports().contains(blankProbeAttemptedPort_)) { + blankProbeAttemptedPort_.clear(); + } + reconcile(); + maybeScheduleBlankBoardProbe(); + }); connect(device_, &DeviceController::logTextChanged, this, [this] { refreshFirmwareIdentity(); reconcile(); @@ -270,6 +343,20 @@ void SmartSessionController::reconnectFirmwareSignals() { if (firmware_ == nullptr) return; connect(firmware_, &FirmwareManager::stateChanged, this, [this] { + if (blankProbeInFlight_) { + if (firmware_->busy()) { + reconcile(); + return; + } + + blankProbeInFlight_ = false; + blankBoardDetected_ = firmware_->targetVerified() && + firmware_->selectedPort() == blankBoardPort_; + if (!blankBoardDetected_) blankBoardPort_.clear(); + reconcile(); + return; + } + if (!updateRequested_) { reconcile(); return; @@ -305,9 +392,14 @@ void SmartSessionController::reconnectFirmwareSignals() { updateStage_ = UpdateStage::idle; emit firmwareUpdateFinished(false); } - } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy() && - firmware_->bootloaderHelpNeeded()) { - updateStage_ = UpdateStage::waitingForBootloader; + } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy()) { + if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } else { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); + } } reconcile(); }); @@ -320,7 +412,7 @@ void SmartSessionController::reconnectFirmwareSignals() { updateStage_ = UpdateStage::reconnecting; setPresentation( QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Firmware installed. Reconnecting to ESP32-P4…"), + QStringLiteral("Firmware written. Reconnecting to ESP32-P4…"), false, false); reconnectTimer_.start(); @@ -339,6 +431,27 @@ void SmartSessionController::reconnectFirmwareSignals() { }); } +void SmartSessionController::maybeScheduleBlankBoardProbe() { + if (!started_ || device_ == nullptr || firmware_ == nullptr || updateRequested_ || + blankProbeInFlight_ || blankBoardDetected_ || blankProbeTimer_.isActive() || + device_->deviceVerified() || device_->discovering() || device_->connected() || + firmware_->busy() || !firmware_->bundleReady()) { + return; + } + + const QString port = device_->recommendedPort().trimmed(); + if (port.isEmpty() || port == blankProbeAttemptedPort_) return; + blankProbeTimer_.start(); +} + +void SmartSessionController::clearBlankBoardContext() { + blankProbeTimer_.stop(); + blankProbeInFlight_ = false; + blankBoardDetected_ = false; + blankBoardPort_.clear(); + blankProbeAttemptedPort_.clear(); +} + bool SmartSessionController::ensureDefaultProfile() { if (profiles_ == nullptr) return false; if (profiles_->hasProfiles()) return true; @@ -416,7 +529,7 @@ void SmartSessionController::reconcile() { (updateStage_ == UpdateStage::reconnecting && !device_->deviceVerified())) { setPresentation( QStringLiteral("UPDATING FIRMWARE"), - updateStatus().isEmpty() ? QStringLiteral("Updating firmware…") : updateStatus(), + updateStatus().isEmpty() ? QStringLiteral("Installing firmware…") : updateStatus(), false, false); return; @@ -429,11 +542,31 @@ void SmartSessionController::reconcile() { } if (!device_->deviceVerified()) { + if (blankProbeInFlight_) { + setPresentation( + QStringLiteral("CHECKING DEVICE"), + QStringLiteral("Checking the ESP32-P4 firmware state…"), + false, + false); + return; + } + + if (blankBoardDetected_) { + setPresentation( + QStringLiteral("FIRMWARE REQUIRED"), + QStringLiteral("ESP32-P4 detected on %1. ARStack firmware is not installed or is not responding.") + .arg(blankBoardPort_), + false, + false); + return; + } + + maybeScheduleBlankBoardProbe(); setPresentation( device_->ports().isEmpty() ? QStringLiteral("WAITING FOR DEVICE") : QStringLiteral("DEVICE FOUND"), device_->ports().isEmpty() ? QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.") - : QStringLiteral("USB device found. ARStack Studio will connect automatically."), + : QStringLiteral("USB device found. ARStack Studio is identifying it automatically."), false, false); return; @@ -461,6 +594,7 @@ void SmartSessionController::reconcile() { if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { updateRequested_ = false; updateStage_ = UpdateStage::idle; + clearBlankBoardContext(); emit firmwareUpdateFinished(true); } From bcb10c7c1bebbd829a0503c388c9efa74177f1fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:08:44 +0700 Subject: [PATCH 111/272] feat(studio): add novice blank-board firmware onboarding popup --- apps/arstack_studio/qml/WorkflowBar.qml | 284 ++++++++++++++++++++++-- 1 file changed, 270 insertions(+), 14 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 0142885b1..534cfc7e3 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -14,6 +14,10 @@ SurfacePanel { property string monoFont: "Inter" property bool compact: false property bool updatePromptDeferred: false + property bool installPromptDeferred: false + property bool firmwareResultSuccess: false + property string firmwareResultTitle: "" + property string firmwareResultMessage: "" SmartSessionController { id: smartSession @@ -35,16 +39,44 @@ SurfacePanel { controller.applyAllSignals() } function onStateChanged() { - if (smartSession.firmwareUpdateRequired && !ribbon.updatePromptDeferred && !updateDialog.opened) + if (smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) { + if (installDialog.opened) + installDialog.close() + if (updateDialog.opened) + updateDialog.close() + if (!progressDialog.opened) + Qt.callLater(progressDialog.open) + return + } + if (smartSession.firmwareInstallRequired && !ribbon.installPromptDeferred && + !installDialog.opened && !progressDialog.opened) { + Qt.callLater(installDialog.open) + return + } + if (smartSession.firmwareUpdateRequired && !ribbon.updatePromptDeferred && + !updateDialog.opened && !progressDialog.opened) { Qt.callLater(updateDialog.open) + } } function onFirmwareUpdateFinished(success) { + if (progressDialog.opened) + progressDialog.close() + + ribbon.firmwareResultSuccess = success if (success) { ribbon.updatePromptDeferred = false - controller.showMessage("Firmware updated. Preparing injection…", false) + ribbon.installPromptDeferred = false + ribbon.firmwareResultTitle = "Firmware installed successfully" + ribbon.firmwareResultMessage = "ESP32-P4 restarted and ARStack firmware was verified. Studio is preparing the 4I+4V injector." + controller.showMessage("Firmware installed and verified successfully.", false) } else { - controller.showMessage("Firmware update did not complete. Open Advanced if recovery help is needed.", true) + ribbon.firmwareResultTitle = "Firmware installation not completed" + ribbon.firmwareResultMessage = FirmwareService.status.length > 0 + ? FirmwareService.status + : "Studio could not verify the firmware installation. Keep the board connected and try again." + controller.showMessage("Firmware installation was not verified.", true) } + Qt.callLater(resultDialog.open) } } @@ -54,13 +86,21 @@ SurfacePanel { if (!device.deviceVerified) ribbon.updatePromptDeferred = false } + function onPortsChanged() { + if (device.ports.length === 0) { + ribbon.installPromptDeferred = false + ribbon.updatePromptDeferred = false + } + } } readonly property string displayState: { if (smartSession.state === "WAITING FOR DEVICE") return "Connect device" if (smartSession.state === "DEVICE FOUND" || smartSession.state === "CONNECTING") return "Connecting" + if (smartSession.state === "CHECKING DEVICE") return "Checking device" + if (smartSession.state === "FIRMWARE REQUIRED") return "Firmware required" if (smartSession.state === "FIRMWARE UPDATE") return "Firmware update available" - if (smartSession.state === "UPDATING FIRMWARE") return "Updating firmware" + if (smartSession.state === "UPDATING FIRMWARE") return "Installing firmware" if (smartSession.state === "UPDATE NEEDS BOOT") return "Download mode required" if (smartSession.state === "PREPARING 4I+4V") return "Preparing" if (smartSession.state === "READY") return "Ready" @@ -72,7 +112,8 @@ SurfacePanel { readonly property color smartStateColor: { if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green - if (smartSession.state === "CONNECTING" || smartSession.state === "PREPARING 4I+4V" || + if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE" || + smartSession.state === "FIRMWARE REQUIRED" || smartSession.state === "PREPARING 4I+4V" || smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || smartSession.state === "UPDATE NEEDS BOOT") return theme.amber if (smartSession.state === "DEVICE FOUND") return theme.accent @@ -82,14 +123,87 @@ SurfacePanel { function startReason() { if (device.running) return "SMV output is already running." + if (smartSession.firmwareInstallRequired) return "Install ARStack firmware before starting injection." if (smartSession.firmwareUpdateRequired) return "Update firmware before starting injection." - if (smartSession.updatingFirmware) return "Firmware update is in progress." - if (smartSession.state === "CONNECTING") return "Connecting to the device automatically." + if (smartSession.updatingFirmware) return "Firmware installation is in progress." + if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE") return "Studio is identifying the device automatically." if (smartSession.state === "PREPARING 4I+4V") return "Preparing the default 4I+4V injection automatically." if (smartSession.state === "WAITING FOR DEVICE") return "Connect ESP32-P4; Studio will detect it automatically." return smartSession.statusText } + Dialog { + id: installDialog + modal: true + anchors.centerIn: Overlay.overlay + width: 420 + title: "Set up ESP32-P4" + closePolicy: Popup.NoAutoClose + + background: Rectangle { + color: ribbon.theme.surface + radius: 10 + border.width: 1 + border.color: ribbon.theme.line + } + + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: "ESP32-P4 detected" + color: ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 15 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: "ARStack firmware is not installed or is not responding on this board. Studio can install the correct firmware automatically." + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: "No firmware file, ESP-IDF, Python, or command line is required. Keep the USB cable connected until Studio confirms success." + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Later" + onClicked: { + ribbon.installPromptDeferred = true + installDialog.close() + } + } + CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Install firmware" + tone: "accent" + implicitWidth: 142 + onClicked: { + if (smartSession.beginFirmwareInstall()) { + ribbon.installPromptDeferred = false + installDialog.close() + } else { + controller.showMessage("Firmware installation could not start. Open Advanced for recovery details.", true) + } + } + } + } + } + } + Dialog { id: updateDialog modal: true @@ -118,7 +232,7 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "Update now? Studio will install it, restart the board, reconnect, and return to Ready automatically." + text: "Update now? Studio will install it, restart the board, reconnect, and verify the result automatically." color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 @@ -155,6 +269,137 @@ SurfacePanel { } } + Dialog { + id: progressDialog + modal: true + anchors.centerIn: Overlay.overlay + width: 450 + title: smartSession.updateNeedsBootloaderHelp ? "Device needs Download mode" : "Installing firmware" + closePolicy: Popup.NoAutoClose + + background: Rectangle { + color: ribbon.theme.surface + radius: 10 + border.width: 1 + border.color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.line + } + + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: smartSession.updateNeedsBootloaderHelp + ? "Studio cannot reach the ESP32-P4 bootloader yet." + : "Keep the USB cable connected. Studio will verify the board before reporting success." + color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 13 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: smartSession.updateStatus.length > 0 ? smartSession.updateStatus : "Preparing firmware installation…" + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + ProgressBar { + visible: !smartSession.updateNeedsBootloaderHelp + Layout.fillWidth: true + from: 0 + to: 100 + indeterminate: smartSession.firmwareProgress < 0 + value: smartSession.firmwareProgress < 0 ? 0 : smartSession.firmwareProgress + } + Label { + visible: !smartSession.updateNeedsBootloaderHelp && smartSession.firmwareProgress >= 0 + Layout.fillWidth: true + text: smartSession.firmwareProgress + "%" + color: ribbon.theme.text + font.family: ribbon.monoFont + font.pixelSize: 12 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + } + RowLayout { + visible: smartSession.updateNeedsBootloaderHelp + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Retry" + tone: "accent" + implicitWidth: 110 + onClicked: smartSession.retryFirmwareUpdate() + } + } + } + } + + Dialog { + id: resultDialog + modal: true + anchors.centerIn: Overlay.overlay + width: 430 + title: ribbon.firmwareResultSuccess ? "Success" : "Firmware result" + closePolicy: Popup.NoAutoClose + + background: Rectangle { + color: ribbon.theme.surface + radius: 10 + border.width: 1 + border.color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.red + } + + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: ribbon.firmwareResultSuccess ? "✓" : "!" + color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.red + font.family: ribbon.uiFont + font.pixelSize: 30 + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + } + Label { + Layout.fillWidth: true + text: ribbon.firmwareResultTitle + color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 17 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: ribbon.firmwareResultMessage + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "OK" + tone: ribbon.firmwareResultSuccess ? "success" : "normal" + implicitWidth: 100 + onClicked: resultDialog.close() + } + Item { Layout.fillWidth: true } + } + } + } + implicitHeight: 88 color: theme.surface2 border.color: theme.line @@ -279,21 +524,21 @@ SurfacePanel { spacing: 8 Label { visible: !ribbon.compact - text: "Updating firmware" + text: "Installing firmware" color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 10 } ProgressBar { - indeterminate: FirmwareService.flashProgress < 0 + indeterminate: smartSession.firmwareProgress < 0 from: 0 to: 100 - value: FirmwareService.flashProgress < 0 ? 0 : FirmwareService.flashProgress + value: smartSession.firmwareProgress < 0 ? 0 : smartSession.firmwareProgress Layout.preferredWidth: ribbon.compact ? 125 : 190 } Label { - visible: FirmwareService.flashProgress >= 0 - text: FirmwareService.flashProgress + "%" + visible: smartSession.firmwareProgress >= 0 + text: smartSession.firmwareProgress + "%" color: ribbon.theme.textSoft font.family: ribbon.monoFont font.pixelSize: 10 @@ -320,6 +565,17 @@ SurfacePanel { onClicked: smartSession.retryFirmwareUpdate() } + ActionButton { + visible: smartSession.firmwareInstallRequired && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp + text: "Install firmware" + tone: "accent" + implicitWidth: 142 + onClicked: { + ribbon.installPromptDeferred = false + installDialog.open() + } + } + ActionButton { visible: smartSession.firmwareUpdateRequired && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Update firmware" @@ -332,7 +588,7 @@ SurfacePanel { } ActionButton { - visible: !device.running && !smartSession.firmwareUpdateRequired && + visible: !device.running && !smartSession.firmwareInstallRequired && !smartSession.firmwareUpdateRequired && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Start" iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") From a546fe3af19df37eb8de736ff800f078fad093c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:09:51 +0700 Subject: [PATCH 112/272] chore: carry production AGENTS contract into P0 branch --- AGENTS.md | 283 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8335ceebd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,283 @@ +# AGENTS.md — arstack61850 Production Engineering Contract + +These rules apply to every AI/code agent working in this repository. arstack61850 is protocol/embedded engineering software. Correctness, deterministic timing, bounded resource use, interoperability, and failure containment are product requirements from the first implementation. + +## 1. Prime directive + +Do not begin with a disposable, intentionally naive, prototype-only implementation when the production architecture is knowable. + +Choose the smallest production-quality design that satisfies the requirement without speculative complexity. + +Priority order: +1. protocol correctness and data integrity; +2. deterministic/fail-safe behavior; +3. regression/interoperability compatibility; +4. bounded latency, CPU, memory, and queue depth; +5. maintainability and testability. + +Never trade a working IEC 61850 capability for a shortcut that only makes a local demo pass. + +## 2. Mandatory workflow + +For non-trivial changes: + +RECONNAISSANCE -> REPRODUCE/BASELINE -> ROOT CAUSE -> INVARIANTS -> ARCHITECTURE IMPACT -> IMPLEMENT -> REGRESSION TEST -> NEGATIVE/FAILURE TEST -> PERFORMANCE/TIMING CHECK -> BUILD/CI -> INTEROP VALIDATION + +Before editing: +- locate the current protocol path, state machine, buffer ownership, callback/lifecycle owner, and all consumers; +- identify wire-format, timing, sequence, dataset, control-block, and public API invariants; +- determine root cause before patching symptoms; +- avoid creating a second parser/state machine/cache/source of truth for behavior that already exists; +- preserve portable core behavior unless a platform-specific boundary is explicitly required. + +If one approach fails, stop and reassess assumptions. Do not stack workaround upon workaround. + +## 3. Architecture boundaries + +Keep the protocol core independent from UI, shell, packaging, and platform glue. + +Preferred conceptual direction: + +Protocol/Core models and codecs +-> Session/state machines and application services +-> Platform adapters (socket, timer, filesystem, NIC, OS) +-> GUI/CLI/embedded integration + +Rules: +- wire encoding/decoding must not depend on UI code; +- ESP32, Windows/Linux, and test transports should adapt to shared protocol semantics instead of forking them; +- avoid global mutable protocol state; +- explicit ownership is required for sessions, buffers, timers, sockets, datasets, reports, control blocks, and callbacks; +- do not introduce abstraction layers with no current use. + +## 4. Defensive protocol parsing + +Treat every received frame, file, configuration, and remote endpoint as fallible. + +Before accessing fields: +- validate minimum header length; +- validate declared payload length against available bytes; +- validate offsets, counts, indexes, and alignment assumptions; +- validate enum/tag/type values; +- validate integer arithmetic for overflow/underflow; +- validate string/BER/MMS lengths before allocation or copy; +- reject or safely isolate malformed/truncated inputs. + +Never index or dereference based only on untrusted wire metadata. + +In C/C++ use explicit bounds checks, RAII where available, scoped ownership, `std::optional`/typed result/error patterns where appropriate, and safe integer conversions. + +Do not hide errors with broad catch-all handling. Fail at a meaningful boundary and preserve process/session availability when isolation is possible. + +A malformed packet must not crash the analyzer, GUI, service, or embedded target. + +## 5. State machines must be explicit + +IEC 61850 association, report, GOOSE, Sampled Values, control, file transfer, discovery, reconnect, and embedded generation logic must use explicit states/transitions where state exists. + +Never repair a state-machine race with arbitrary sleeps or retries. + +Every retry policy must be bounded and have: +- maximum attempt or timeout behavior; +- cancellation/shutdown behavior; +- clear transition after terminal failure; +- no unbounded reconnect or busy loop. + +Unexpected or out-of-order traffic must not corrupt session state. + +## 6. Zero blocking on latency-sensitive paths + +Do not perform blocking filesystem, DNS/network setup, logging, allocation-heavy formatting, GUI work, or unrelated computation inside high-frequency packet callbacks, transmit loops, ISR-adjacent paths, or timing-critical workers. + +On desktop, expensive work belongs on bounded background execution rather than the UI thread. + +On embedded targets, timing-critical work must remain deterministic and bounded. Avoid dynamic behavior whose worst-case cost is unknown. + +Never spawn one thread/task per packet or event. + +## 7. Bounded queues and backpressure + +All producer/consumer paths must have an explicit overload policy. + +Avoid unbounded queues for: +- captured frames; +- MMS responses; +- GOOSE events; +- SV samples; +- logs; +- GUI updates; +- transmit requests; +- device telemetry. + +Use bounded ring buffers, batching, coalescing, sampling, drop policies, or backpressure according to semantics. + +When loss is acceptable for presentation/telemetry, prefer dropping/coalescing stale intermediate data over growing memory without bound. + +When loss is not acceptable, apply backpressure or fail explicitly rather than silently corrupting order/data. + +## 8. Sampled Values / GOOSE / generator timing + +For periodic transmit and sample generation: +- avoid heap allocation in the steady-state hot loop; +- precompute invariant frame sections when practical; +- reuse packet buffers; +- bound per-cycle work; +- keep logging/diagnostics off the timing-critical path; +- separate configuration changes from the real-time send loop using safe handoff; +- measure jitter, missed deadlines, CPU load, queue depth, and sustained-rate behavior where applicable. + +Never claim real-time behavior from average timing alone. Consider worst-case and sustained load. + +## 9. Memory discipline + +Embedded and high-rate desktop paths must avoid unnecessary allocation churn. + +Prefer: +- preallocated/reused packet buffers; +- fixed-capacity or bounded containers when maximums are known; +- spans/views instead of copies; +- incremental parsing instead of duplicated whole-message structures; +- deterministic cleanup through RAII/scoped ownership. + +Use object pools only when the lifetime model is safe and profiling/measurement shows value. + +Never keep raw pointers to buffers whose ownership/lifetime can end asynchronously. + +## 10. Files, SCL, captures, and large data + +Do not eagerly duplicate large captures/SCL datasets into multiple complete representations. + +Prefer streaming/chunked parsing and indexed access when practical. + +Validate malformed XML/SCL, missing references, inconsistent datasets, invalid addressing, unsupported editions/features, and oversized declarations. + +Do not invent engineering values to compensate for missing source data. Preserve unknown/invalid states explicitly. + +## 11. Thread safety and shutdown + +Every worker, socket, timer, queue, callback registration, file handle, mapping, and platform handle must have a clear owner and shutdown path. + +Shutdown/reconfigure must: +- signal cancellation/stop; +- prevent new work from entering; +- drain/drop according to documented semantics; +- join/retire workers safely; +- release sockets/handles/buffers; +- prevent callbacks into destroyed objects. + +Do not detach threads merely to avoid lifecycle work. + +## 12. Interoperability over imitation + +Do not cosmetically alter protocol behavior to imitate a specific vendor trace/tool. + +Wire behavior must follow the implemented IEC 61850 contract, supported edition/profile, and documented project compatibility requirements. + +When matching another implementation, distinguish clearly between: +- required standard behavior; +- accepted de-facto interoperability behavior; +- vendor-specific behavior; +- unsupported behavior. + +Add deterministic fixtures for every compatibility quirk that is intentionally supported. + +## 13. Performance and timing contracts + +Performance-sensitive changes should identify relevant measurements, such as: +- messages/s or samples/s; +- sustained CPU load; +- allocation rate/heap high-water mark; +- queue depth/drop count; +- transmit jitter/deadline misses; +- parse throughput; +- reconnect/association latency; +- firmware task stack usage; +- desktop UI latency when applicable. + +Do not claim optimization without evidence. + +Prefer simpler algorithms and bounded data structures over speculative micro-optimization. + +## 14. ESP32 / embedded addendum + +For ESP32 targets: +- no blocking network/file/console operations in timing-critical tasks; +- avoid uncontrolled heap allocation after steady state begins; +- size task stacks intentionally and monitor high-water marks; +- use bounded FreeRTOS queues/ring buffers; +- avoid priority inversion and long critical sections; +- keep ISR work minimal and defer processing safely; +- watchdog behavior must expose actual deadlock/stall problems, not be disabled as a workaround; +- reconnect/network failures must not wedge the main application loop; +- firmware loading/update paths must validate image metadata, size, target compatibility, integrity where available, and failure recovery before replacing working firmware. + +## 15. Exception-free hot paths, explicit Result/status, and async diagnostics + +Expected/recoverable failures must not use exceptions as normal control flow in protocol, parsing, generator, capture, callback, or embedded timing-critical paths. Use explicit status/result contracts such as project-defined `Result`, `std::expected` where the configured C++ standard/library supports it, compact status enums/structs, or `std::optional` only when detailed failure information is unnecessary. + +Normal conditions such as malformed length, unsupported tag/value, timeout, disconnect, buffer exhaustion, invalid configuration, missing optional data, or rejected frame must return a deterministic status rather than throw through the hot path. + +Exceptions from host OS, filesystem/network wrappers, STL/third-party desktop libraries, or integration code may still occur. Catch them at the nearest meaningful platform/infrastructure boundary and convert them to the same structured status/error model. Never allow exception unwinding through SV/GOOSE transmit loops, receive/decode callbacks, ISR-adjacent code, or deterministic embedded tasks. Use `noexcept` only when every reachable operation actually honors the contract; do not add it merely to hide unsafe code. + +Hot-path diagnostics must enqueue only compact structured events, for example `{errorCode, subsystem, numericContext, timestamp/counter}`. Do not allocate/format strings, write files, print console logs, serialize JSON, perform network telemetry, or block while reporting an error from a timing-critical path. + +The diagnostic transport must be bounded and non-blocking for real-time producers. Prefer a fixed-capacity/SPSC ring buffer where the producer/consumer topology allows it, or another bounded queue with a documented overload policy. When full, aggregate/deduplicate/rate-limit repeated events, increment dropped-event counters, and preserve high-severity/latest information according to policy; never stall SV/GOOSE timing or packet processing because diagnostics are slow. + +Human-readable formatting, persistence, telemetry, and GUI display belong on a lower-priority background consumer. The diagnostic subsystem is observational: its own failure must not deadlock, reset, or corrupt the protocol stack. + +Do not create multiple incompatible result/error frameworks. Reuse one lightweight machine-readable error-code model across related protocol/core subsystems, with contextual formatting outside hot paths. + +## 16. Regression prevention + +Every protocol bug fix should add/update a deterministic regression test whenever technically practical. + +Test the exact failure mode: malformed lengths, disconnect timing, sequence/order issue, timeout, wraparound, unsupported data, buffer boundary, state transition, or platform-specific failure. + +Before changing public structs/APIs, binary/wire formats, config semantics, timing defaults, or supported behavior, evaluate compatibility with existing consumers and tests. + +## 17. Change discipline + +Prefer the smallest coherent root-cause fix. + +Do not: +- mix unrelated refactoring into a focused bug fix; +- duplicate parsers/state machines/services; +- rewrite a working subsystem because a local patch is difficult to understand; +- add dependencies without evaluating size, portability, security, maintenance, and runtime impact; +- add background workers/caches merely as generic performance patterns. + +## 18. Definition of done + +A task is not complete because it compiles. + +Validate as applicable: +BUILD ++ STATIC ANALYSIS ++ UNIT/DETERMINISTIC FIXTURE TESTS ++ REGRESSION TEST ++ MALFORMED/NEGATIVE TESTS ++ TIMING/PERFORMANCE CHECK ++ RESOURCE/LIFECYCLE CHECK ++ DESKTOP/EMBEDDED TARGET BUILD ++ SIMULATOR/LOOPBACK ++ AUTHORIZED INTEROP/LAB VALIDATION + +Never claim a validation step was run when it was not. + +## 19. Agent completion report + +Report: +- Changed; +- Root cause; +- Architecture decision; +- Invariants preserved; +- Regression protection; +- Timing/performance impact; +- exact validation executed; +- remaining genuine limitations. + +## Final rule + +Think like the engineer responsible for a protocol stack and embedded device under sustained field load for years, not like a prototype generator trying to make one packet or screenshot pass. + +Understand first. Bound every hot path. Make states explicit. Preserve protocol semantics. Validate malformed inputs. Measure timing. Prevent regressions. From 09e6a9be3e725a928ba605264e84dce8494122ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:10:20 +0700 Subject: [PATCH 113/272] fix(studio): expose structured firmware operation failures --- apps/arstack_studio/src/FirmwareManager.hpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index ca9a053d9..627100b49 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -41,13 +41,7 @@ class FirmwareManager : public QObject { [[nodiscard]] QString bundleStatus() const { return bundleStatus_; } [[nodiscard]] QString logText() const { return logText_; } - // espflash 4.x prints its Chip Display value as "esp32p4", while some - // Espressif tooling prints "ESP32-P4". Keep one parser for both forms and - // regression-test it from the packaged application contract. [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); - // Returns the newest valid 0..100 percentage in a chunk, or -1 when the - // flasher output carries no usable progress token. Cosmetic parsing must - // never decide whether a flash succeeds or fails. [[nodiscard]] static int parseFlashProgress(const QString& output); Q_INVOKABLE void refreshBundle(); @@ -60,6 +54,7 @@ class FirmwareManager : public QObject { void stateChanged(); void logChanged(); void installationFinished(bool resetSucceeded); + void operationFailed(const QString& message, bool bootloaderHelpNeeded); private: enum class Operation { none, probe, flash, reset }; From 36e7c40431045ab84b2e06e4d120b1eefeb98018 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:11:09 +0700 Subject: [PATCH 114/272] fix(studio): keep flash/reset failure semantics deterministic --- apps/arstack_studio/src/FirmwareManager.cpp | 29 +++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 4bef77dc0..075660c27 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -43,9 +43,18 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { busy_ = false; flashProgress_ = -1; process_.kill(); + + if (failedOperation == Operation::reset) { + setStatus(QStringLiteral("Firmware was written, but the reset tool did not start. Press RESET once or reconnect USB; Studio will verify the board.")); + emit stateChanged(); + emit installationFinished(false); + return; + } + fail(QStringLiteral("Firmware tool did not start within 2.5 seconds.")); - if (failedOperation == Operation::probe) bootloaderHelpNeeded_ = false; + bootloaderHelpNeeded_ = false; emit stateChanged(); + emit operationFailed(status_, false); }); connect(&process_, &QProcess::started, this, [this] { @@ -183,7 +192,7 @@ bool FirmwareManager::loadManifest() { QCryptographicHash hasher{QCryptographicHash::Sha256}; if (!hasher.addData(&image)) { - bundleStatus_ = QStringLiteral("Unable to hash the firmware image."); + bundleStatus_ = QStringLiteral("Unable to hash the firmware image.")); return false; } const QString actualHash = QString::fromLatin1(hasher.result().toHex()).toLower(); @@ -263,12 +272,15 @@ void FirmwareManager::cancel() { emit stateChanged(); if (process_.state() == QProcess::NotRunning) { + const Operation cancelledOperation = operation_; cancelRequested_ = false; operation_ = Operation::none; busy_ = false; flashProgress_ = -1; setStatus(QStringLiteral("Firmware operation cancelled.")); emit stateChanged(); + if (cancelledOperation == Operation::reset) emit installationFinished(false); + else emit operationFailed(status_, false); return; } process_.kill(); @@ -306,12 +318,23 @@ void FirmwareManager::handleProcessError(const QProcess::ProcessError error) { if (error != QProcess::FailedToStart || operation_ == Operation::none) return; startupTimer_.stop(); + const Operation failedOperation = operation_; operation_ = Operation::none; busy_ = false; flashProgress_ = -1; cancelRequested_ = false; + + if (failedOperation == Operation::reset) { + setStatus(QStringLiteral("Firmware was written, but the reset tool could not start. Press RESET once or reconnect USB; Studio will verify the board.")); + emit stateChanged(); + emit installationFinished(false); + return; + } + fail(QStringLiteral("Unable to start bundled espflash: %1").arg(process_.errorString())); + bootloaderHelpNeeded_ = false; emit stateChanged(); + emit operationFailed(status_, false); } void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitStatus exitStatus) { @@ -326,6 +349,8 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt flashProgress_ = -1; setStatus(QStringLiteral("Firmware operation cancelled.")); emit stateChanged(); + if (completed == Operation::reset) emit installationFinished(false); + else emit operationFailed(status_, false); return; } From 6bec34a75708eda8219ff9d2ce32bcd71acacd07 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:12:04 +0700 Subject: [PATCH 115/272] fix(studio): correct firmware manager failure path and hash branch --- apps/arstack_studio/src/FirmwareManager.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 075660c27..7760de201 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -192,7 +192,7 @@ bool FirmwareManager::loadManifest() { QCryptographicHash hasher{QCryptographicHash::Sha256}; if (!hasher.addData(&image)) { - bundleStatus_ = QStringLiteral("Unable to hash the firmware image.")); + bundleStatus_ = QStringLiteral("Unable to hash the firmware image."); return false; } const QString actualHash = QString::fromLatin1(hasher.result().toHex()).toLower(); From d57850cd9d15f374125350cf4e87101c7a53eb46 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:12:59 +0700 Subject: [PATCH 116/272] fix(studio): separate terminal firmware failure from reset completion --- .../src/SmartSessionController.cpp | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 060e6b8d7..450c99618 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -392,14 +392,25 @@ void SmartSessionController::reconnectFirmwareSignals() { updateStage_ = UpdateStage::idle; emit firmwareUpdateFinished(false); } - } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy()) { - if (firmware_->bootloaderHelpNeeded()) { - updateStage_ = UpdateStage::waitingForBootloader; - } else { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; - emit firmwareUpdateFinished(false); - } + } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy() && + firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } + reconcile(); + }); + + connect(firmware_, &FirmwareManager::operationFailed, this, + [this](const QString&, const bool bootloaderHelpNeeded) { + if (!updateRequested_) { + reconcile(); + return; + } + if (bootloaderHelpNeeded) { + updateStage_ = UpdateStage::waitingForBootloader; + } else { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + emit firmwareUpdateFinished(false); } reconcile(); }); From 0a652db744c8bcb39504813278d76c06332aa1a1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:14:45 +0700 Subject: [PATCH 117/272] test(studio): expose ESP32-P4 revision policy predicate --- apps/arstack_studio/src/FirmwareManager.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 627100b49..5fe049531 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -42,6 +42,7 @@ class FirmwareManager : public QObject { [[nodiscard]] QString logText() const { return logText_; } [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); + [[nodiscard]] static bool supportsEsp32P4Revision(int major, int minor) noexcept; [[nodiscard]] static int parseFlashProgress(const QString& output); Q_INVOKABLE void refreshBundle(); From e6f90bd30f27a61a77091fc3e59be7a29ad88373 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:15:44 +0700 Subject: [PATCH 118/272] test(studio): lock pre-v3 ESP32-P4 firmware target policy --- apps/arstack_studio/src/FirmwareManager.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 7760de201..1682666ff 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -94,6 +94,10 @@ bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, in return true; } +bool FirmwareManager::supportsEsp32P4Revision(const int major, const int minor) noexcept { + return major >= 0 && major < 3 && minor >= 0; +} + int FirmwareManager::parseFlashProgress(const QString& output) { static const QRegularExpression expression{QStringLiteral(R"((\d{1,3})\s*%)")}; auto matches = expression.globalMatch(output); @@ -364,7 +368,8 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt const bool anyChipAnswered = success && operationOutput_.contains( QRegularExpression{QStringLiteral(R"(Chip\s+type:)"), QRegularExpression::CaseInsensitiveOption}); const bool supportedRevision = p4WithRevision && - revisionPolicy_ == QString::fromLatin1(kPreV3Policy) && major < 3; + revisionPolicy_ == QString::fromLatin1(kPreV3Policy) && + supportsEsp32P4Revision(major, minor); targetVerified_ = supportedRevision; bootloaderHelpNeeded_ = !success; From 2cb0ab5a7488bf14534c9a9af108323e44380ec9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:16:07 +0700 Subject: [PATCH 119/272] test(studio): cover blank-board revision and progress safety contract --- apps/arstack_studio/src/main.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 1fdaed57e..7a3686e87 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -81,6 +81,14 @@ int checkFirmwareContract(int argc, char* argv[]) { wrongMajor, wrongMinor); + const bool revisionPolicy = + FirmwareManager::supportsEsp32P4Revision(0, 0) && + FirmwareManager::supportsEsp32P4Revision(1, 3) && + FirmwareManager::supportsEsp32P4Revision(2, 99) && + !FirmwareManager::supportsEsp32P4Revision(3, 0) && + !FirmwareManager::supportsEsp32P4Revision(-1, 0) && + !FirmwareManager::supportsEsp32P4Revision(1, -1); + const bool flashProgressFormat = FirmwareManager::parseFlashProgress(QStringLiteral("Writing 7%\rWriting 64%\r")) == 64 && FirmwareManager::parseFlashProgress(QStringLiteral("no progress token")) == -1 && @@ -90,7 +98,7 @@ int checkFirmwareContract(int argc, char* argv[]) { firmware.firmwareVersion() == QStringLiteral(ARSTACK_STUDIO_VERSION) && firmware.expectedProtocol() == QStringLiteral("1") && firmware.firmwareSha256().size() == 64 && - realEspflashFormat && dashedFormat && rejectsWrongChip && flashProgressFormat; + realEspflashFormat && dashedFormat && rejectsWrongChip && revisionPolicy && flashProgressFormat; if (!valid) { qCritical().noquote() << "Firmware bundle/probe contract: FAIL ·" @@ -98,11 +106,12 @@ int checkFirmwareContract(int argc, char* argv[]) { << "espflash-format=" << realEspflashFormat << "dashed-format=" << dashedFormat << "wrong-chip-rejected=" << rejectsWrongChip + << "revision-policy=" << revisionPolicy << "progress-format=" << flashProgressFormat; return 4; } qInfo().noquote() - << "Firmware bundle/probe contract: PASS · P4 revision + flash progress formats accepted ·" + << "Firmware bundle/probe contract: PASS · target/revision/progress/hash policy locked ·" << firmware.bundleStatus(); return 0; } From 7c2baca9972c76a2a4e2cbee05e4a6b060084921 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:17:17 +0700 Subject: [PATCH 120/272] test(studio): reject wrong-chip firmware manifests --- .github/workflows/arstack-studio-qt.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 6f27a480f..2fcc9ebb3 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -126,6 +126,8 @@ jobs: ./build-arstack-studio/arstack_studio --check-firmware-contract \ 2>&1 | tee /tmp/arstack-studio-firmware-contract.log + cp "$root/firmware/firmware-manifest.json" "$root/firmware/firmware-manifest.valid.json" + zeros=0000000000000000000000000000000000000000000000000000000000000000 sed -i "s/\"sha256\": \"$sha\"/\"sha256\": \"$zeros\"/" "$root/firmware/firmware-manifest.json" if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ @@ -137,6 +139,17 @@ jobs: fi echo 'Corrupted firmware hash: correctly blocked' | tee -a /tmp/arstack-studio-firmware-contract.log + cp "$root/firmware/firmware-manifest.valid.json" "$root/firmware/firmware-manifest.json" + sed -i 's/"chip": "esp32p4"/"chip": "esp32s3"/' "$root/firmware/firmware-manifest.json" + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ + >> /tmp/arstack-studio-firmware-contract.log 2>&1; then + echo 'Wrong-chip firmware manifest was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log + exit 1 + fi + echo 'Wrong-chip firmware manifest: correctly blocked' | tee -a /tmp/arstack-studio-firmware-contract.log + - name: Headless QML launch smoke shell: bash run: | From 91017c7066a8460f7f72e271844f5e8c4746749b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:17:59 +0700 Subject: [PATCH 121/272] docs(studio): record Checkpoint H blank-board onboarding --- docs/SMV_STUDIO_SMART_UX_PROGRESS.md | 52 ++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md index e71faac1f..2c09b611f 100644 --- a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -2,7 +2,7 @@ Target operator experience: -`Plug ESP32-P4 -> Studio detects/connects -> firmware checked -> default 4I+4V prepared -> READY -> edit values -> START/STOP` +`Plug ESP32-P4 -> Studio detects/connects -> firmware checked/installed when needed -> default 4I+4V prepared -> READY -> edit values -> START/STOP` The normal operator must not need to understand SCL Class A, profile deployment, APPID/MAC/VLAN, firmware files, SHA-256, espflash, COM-port internals, or PTP unless an exceptional condition requires it. @@ -12,6 +12,14 @@ The normal operator must not need to understand SCL Class A, profile deployment, - 🟡 implemented but CI/hardware/UX acceptance still required - ⬜ not implemented yet +## Production engineering contract + +- ✅ `AGENTS.md` production engineering contract from `main` is carried into the P0 branch. +- ✅ Firmware onboarding follows the existing ownership boundaries: `DeviceController` owns serial discovery, application-wide `FirmwareService` owns package/probe/flash state, and `SmartSessionController` owns the operator state machine. +- ✅ No second firmware parser, second flash service, or QML-owned device state machine was added. +- ✅ Firmware process execution is asynchronous; the UI thread no longer waits synchronously for espflash process start/termination. +- ✅ Process startup is bounded to 2.5 s and firmware operation output is bounded/truncated rather than growing without limit. + ## Smart workflow - ✅ Built-in 4I+4V / 4000 fps / 9-2LE reference profile loads automatically. @@ -26,6 +34,24 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Normal session orchestration lives in native `SmartSessionController` instead of ribbon timing logic. - 🟡 Zero-configuration preparation must still be hardware-tested across app launch, USB replug, STOP/START, and ESP power-cycle. +## Blank / unconfigured ESP32-P4 onboarding + +- ✅ After normal ARStack identity discovery fails, Studio may perform a ROM-level check only on one unique high-confidence Espressif candidate; arbitrary COM ports are not auto-flashed. +- ✅ A ROM-verified supported ESP32-P4 that has no working ARStack identity becomes an explicit `FIRMWARE REQUIRED` operator state. +- ✅ Primary UI automatically offers a novice `Set up ESP32-P4` dialog; the user does not select `.bin` files or need ESP-IDF/Python/CLI tools. +- ✅ User approval remains explicit before any flash starts. +- ✅ `Install firmware` re-verifies the ESP32-P4 target before writing; earlier detection is not blindly trusted. +- ✅ Flash is blocked for another ESP chip, unsupported revision, invalid/missing manifest, invalid offset, missing image, hash mismatch, or missing bundled flasher. +- ✅ The target policy is explicit `ESP32-P4 pre-v3`; deterministic regression coverage accepts v0/v1/v2 and rejects v3+ or invalid revision data. +- ✅ CI negative contract also rejects a firmware manifest whose target chip is changed away from `esp32p4`. +- ✅ Firmware installation uses a modal progress popup. Real espflash percentages render 0..100; probe/reset/reconnect phases remain safely indeterminate. +- ✅ Download-mode recovery is shown in the same popup only when exceptional BOOT/RESET help is needed. +- ✅ A large final result popup explicitly reports `Firmware installed successfully` only after post-flash reconnect and semantic ARStack firmware identity verification. +- ✅ Flash tool success by itself is not treated as final installation success. +- ✅ Failure/cancellation paths exit deterministically instead of leaving the Smart Session in an indefinite flashing state. +- 🟡 Physical blank-board acceptance is still required: factory/blank ESP32-P4 -> prompt -> install -> reset -> reconnect -> semantic identity -> READY. +- 🟡 Physical Download-mode fallback and retry still need acceptance on the target board/USB driver combination. + ## Live values / responsiveness - ✅ Current and voltage edits apply to connected firmware live. @@ -45,27 +71,25 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ ROM/download-mode recovery has guided BOOT/RESET fallback. - ✅ ESP-IDF project version is pinned to `0.1.0` for deterministic semantic firmware identity. - ✅ New firmware `IDENTIFY` appends `firmware=` while preserving the older identity prefix for backwards compatibility. -- ✅ Firmware capabilities now advertise `SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE` when the independent lease timer is available. +- ✅ Firmware capabilities advertise `SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE` when the independent lease timer is available. - ✅ Smart session distinguishes current firmware from legacy/outdated firmware. Missing semantic version is treated as legacy rather than silently accepted. - ✅ Friendly firmware-update prompt is implemented with concise operator wording. - 🟡 One-click update coordinator is implemented: safe STOP if required -> release serial -> ROM probe -> verified bundled flash -> reset -> reconnect -> verify semantic identity -> prepare 4I+4V. - 🟡 If automatic ROM entry fails, normal UI falls back to BOOT + RESET guidance and `Retry`. - ✅ Smart Session and Advanced share one application-wide `FirmwareService`; package/probe/progress state has a single authority. - ✅ Firmware Manager parses real espflash percentage tokens when available and exposes `flashProgress` 0..100; unknown formats safely remain indeterminate. -- ✅ Packaged firmware contract regression-checks progress parsing, including safe fallback for missing/invalid percentages. -- 🟡 Firmware update UI shows numeric progress when espflash emits percentages and automatically falls back to indeterminate during probe/reset or unknown output. -- ⬜ Hardware acceptance of legacy-firmware -> one-click update -> reconnect -> READY is still required. +- ✅ Packaged firmware contract regression-checks target parsing, pre-v3 revision policy, progress parsing, SHA-256 rejection, and wrong-chip manifest rejection. +- 🟡 Hardware acceptance of legacy-firmware -> one-click update -> reconnect -> READY is still required. ## Operator UI simplification - ✅ Primary ribbon reduced to smart state + Balanced + Zero + optional views + Advanced + START/STOP. -- ✅ Smart-session enum names are translated to operator-facing states such as `Connect device`, `Connecting`, `Preparing`, `Ready`, `Running`, and `Firmware update available`. -- ✅ Firmware update dialog is shortened to one decision and hides bootloader/SHA/protocol details unless recovery fails. +- ✅ Smart-session enum names are translated to operator-facing states such as `Connect device`, `Checking device`, `Firmware required`, `Installing firmware`, `Preparing`, `Ready`, and `Running`. +- ✅ Firmware update/install dialogs hide bootloader/SHA/protocol details unless recovery fails. - ✅ Low-level profile deployment is hidden from normal operation. - ✅ Advanced/recovery functionality remains available rather than being deleted. - ✅ Phasor/waveform visual noise is opt-in at startup rather than occupying the default workspace. -- ✅ Firmware internals remain hidden during the normal update path; only exceptional BOOT/RESET recovery is surfaced. -- ✅ Firmware update failure surfaces a concise operator message instead of silently dropping back to an idle state. +- ✅ Firmware update/install failure surfaces a concise operator result instead of silently dropping back to an idle state. - ✅ Advanced configuration header/typography is simplified and enlarged; tabs are `Injection / Firmware / Waveform / Timing / Device` with one `Device ready / Setup mode` status pill. - ✅ Telemetry is collapsed by default and reduced to one quiet status line; expanded mode contains only `Recent activity` and `Transmission` instead of duplicating channel state. - ✅ Main action ribbon uses human-readable state labels instead of raw state-machine names and keeps Ready/Running copy intentionally short. @@ -83,11 +107,11 @@ The normal operator must not need to understand SCL Class A, profile deployment, - ✅ Startup discovery race was removed; initial probe and hot-plug watchdog are serialized by state. - ✅ Smart-session orchestration is centralized in native C++. - ✅ Firmware update completion is not trusted until the newly reconnected firmware reports the expected semantic version. -- ✅ Firmware update state exits deterministically on probe rejection/failure instead of hanging indefinitely. +- ✅ Firmware process startup, update reconnect, and discovery retry behavior are bounded; no unbounded firmware wait/retry loop was added. - ✅ Post-flash reconnect is bounded to six retry windows and waits for active discovery/verification instead of launching overlapping probes. - ✅ Live-edit write backlog is bounded and last-value-wins instead of growing with operator keystrokes. - ✅ Normal application shutdown performs a best-effort STOP before the control process exits while output is RUNNING. -- 🟡 Studio now maintains a silent 700 ms control heartbeat only for current semantic firmware; legacy firmware is not spammed with an unknown command. +- 🟡 Studio maintains a silent 700 ms control heartbeat only for current semantic firmware; legacy firmware is not spammed with an unknown command. - 🟡 Studio sends a fresh heartbeat synchronously before START; a Studio-owned START fails closed if that control-session write cannot be established. - 🟡 Firmware implements a 2.5 s session lease using an independent one-shot `esp_timer`, so lease expiry does not depend on serial input continuing after a GUI crash. - 🟡 Lease timeout handles millisecond-boundary rounding by rearming the remaining interval rather than silently losing the one-shot. @@ -101,8 +125,8 @@ The normal operator must not need to understand SCL Class A, profile deployment, ## Current checkpoint -Checkpoint G — **zero-configuration workflow + semantic firmware update + bounded live writes/reconnect + numeric firmware progress + operator-first visual hierarchy + Studio/firmware control-session lease** is implemented in the branch. +Checkpoint H — **zero-configuration workflow + novice blank-board firmware onboarding + verified popup progress/result + asynchronous bounded FirmwareManager + semantic firmware update + bounded live writes/reconnect + operator-first visual hierarchy + Studio/firmware control-session lease** is implemented in the branch. -Current validation state: **latest-head CI and hardware acceptance are pending.** Do not describe the session lease or this checkpoint as hardware-proven until the ESP32 build passes and the hard-kill/USB tests are completed. +Current validation state: **latest-head CI and physical hardware acceptance are pending.** Do not describe blank-board installation, session lease, or this checkpoint as hardware-proven until the Qt/firmware/release builds pass and the physical blank-board/update/hard-kill/USB tests are completed. -Next checkpoint after CI: finish the remaining `Main.qml` top-level chrome cleanup without disturbing injection logic, build a new Windows RC, then run current-firmware, legacy-update, hard-kill, USB-replug, and sustained 4000 fps hardware acceptance. +Next checkpoint after CI: fix any concrete build/QML regression first; then finish the remaining `Main.qml` top-level chrome cleanup, build a new Windows RC, and run blank-board install, current-firmware, legacy-update, hard-kill, USB-replug, and sustained 4000 fps hardware acceptance. From 70ecfbe62d2ffd9f9f44da3bc8e88f2b4e4a6055 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 08:46:06 +0700 Subject: [PATCH 122/272] fix(esp32p4): declare esp_app_format dependency for semantic firmware identity --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index b50558ebd..13d29b377 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -16,7 +16,8 @@ idf_component_register( "smp_synch_lab.cpp" ${AR_PTP_LAB_SRCS} INCLUDE_DIRS "." - REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) + REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos + PRIV_REQUIRES esp_app_format) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) From 7ed1e68b5ac560ba1961a894691e9037f7b48a90 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 10:43:28 +0700 Subject: [PATCH 123/272] fix(studio): keep Start control visible during firmware gating --- apps/arstack_studio/qml/WorkflowBar.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 534cfc7e3..1e4095dd8 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -588,8 +588,9 @@ SurfacePanel { } ActionButton { - visible: !device.running && !smartSession.firmwareInstallRequired && !smartSession.firmwareUpdateRequired && - !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp + // Keep the primary run affordance stable even when firmware gates execution. + // Safety still comes from startReady=false; the tooltip explains the blocker. + visible: !device.running && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp text: "Start" iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") tone: "success" From 56f8d3d00d8106c61ac644bbb44fae5917518cec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 10:46:43 +0700 Subject: [PATCH 124/272] fix(studio): keep legacy firmware run gate fail-closed --- apps/arstack_studio/qml/WorkflowBar.qml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 1e4095dd8..878f32846 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -26,6 +26,11 @@ SurfacePanel { firmware: FirmwareService } + readonly property bool firmwareGateActive: + smartSession.firmwareInstallRequired || smartSession.firmwareUpdateRequired || + smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp + readonly property bool runGateReady: smartSession.startReady + Component.onCompleted: { controller.phasorDockVisible = false controller.waveformDockVisible = false @@ -39,6 +44,12 @@ SurfacePanel { controller.applyAllSignals() } function onStateChanged() { + // Main.qml still exposes the legacy F5 shortcut through profileDirty. + // Keep that path fail-closed while firmware is missing/outdated so the + // keyboard cannot bypass Smart Session's firmware gate. + if (ribbon.firmwareGateActive) + controller.profileDirty = true + if (smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) { if (installDialog.opened) installDialog.close() @@ -92,6 +103,18 @@ SurfacePanel { ribbon.updatePromptDeferred = false } } + function onProfileStateChanged() { + if (!ribbon.firmwareGateActive) + return + controller.profileDirty = true + // Main.qml also reacts to the same signal and may clear profileDirty + // when a legacy board reports PROFILE armed. Reassert the firmware + // gate after all synchronous signal handlers have completed. + Qt.callLater(function() { + if (ribbon.firmwareGateActive) + controller.profileDirty = true + }) + } } readonly property string displayState: { From 7c89e8acb3225776bd72c950701b8225a0540976 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:17:54 +0700 Subject: [PATCH 125/272] feat(studio): add operator menus and persistent smart workflow --- apps/arstack_studio/qml/WorkflowBar.qml | 569 +++++++++++++----------- 1 file changed, 299 insertions(+), 270 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 878f32846..31e2ce1b0 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -2,6 +2,8 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Dialogs +import QtCore import ARStack.Studio 1.0 SurfacePanel { @@ -19,6 +21,23 @@ SurfacePanel { property string firmwareResultTitle: "" property string firmwareResultMessage: "" + readonly property alias session: smartSession + readonly property bool firmwareGateActive: + smartSession.firmwareInstallRequired || smartSession.firmwareUpdateRequired || + smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp + + Settings { + id: operatorSettings + category: "operator-ui" + property string lastEngineeringUrl: "" + property bool phasorVisible: false + property bool waveformVisible: false + property bool telemetryVisible: true + property bool phasorDetached: false + property bool waveformDetached: false + property bool telemetryExpanded: false + } + SmartSessionController { id: smartSession device: ribbon.device @@ -26,17 +45,36 @@ SurfacePanel { firmware: FirmwareService } - readonly property bool firmwareGateActive: - smartSession.firmwareInstallRequired || smartSession.firmwareUpdateRequired || - smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp - readonly property bool runGateReady: smartSession.startReady - Component.onCompleted: { - controller.phasorDockVisible = false - controller.waveformDockVisible = false + controller.phasorDockVisible = operatorSettings.phasorVisible + controller.waveformDockVisible = operatorSettings.waveformVisible + controller.telemetryDockVisible = operatorSettings.telemetryVisible + controller.phasorDetached = operatorSettings.phasorDetached + controller.waveformDetached = operatorSettings.waveformDetached + controller.telemetryExpanded = operatorSettings.telemetryExpanded + + if (operatorSettings.lastEngineeringUrl.length > 0) { + if (profiles.loadFile(operatorSettings.lastEngineeringUrl)) { + controller.profileDirty = false + controller.showMessage("Last engineering configuration restored.", false) + } else { + operatorSettings.lastEngineeringUrl = "" + controller.showMessage("The previous engineering file is no longer available; using the built-in 4I+4V profile.", false) + } + } smartSession.start() } + Connections { + target: controller + function onPhasorDockVisibleChanged() { operatorSettings.phasorVisible = controller.phasorDockVisible } + function onWaveformDockVisibleChanged() { operatorSettings.waveformVisible = controller.waveformDockVisible } + function onTelemetryDockVisibleChanged() { operatorSettings.telemetryVisible = controller.telemetryDockVisible } + function onPhasorDetachedChanged() { operatorSettings.phasorDetached = controller.phasorDetached } + function onWaveformDetachedChanged() { operatorSettings.waveformDetached = controller.waveformDetached } + function onTelemetryExpandedChanged() { operatorSettings.telemetryExpanded = controller.telemetryExpanded } + } + Connections { target: smartSession function onReadyForLiveApply() { @@ -44,19 +82,15 @@ SurfacePanel { controller.applyAllSignals() } function onStateChanged() { - // Main.qml still exposes the legacy F5 shortcut through profileDirty. - // Keep that path fail-closed while firmware is missing/outdated so the - // keyboard cannot bypass Smart Session's firmware gate. + // Main.qml still exposes legacy keyboard shortcuts. Keep those paths + // fail-closed while firmware is missing or outdated. if (ribbon.firmwareGateActive) controller.profileDirty = true if (smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) { - if (installDialog.opened) - installDialog.close() - if (updateDialog.opened) - updateDialog.close() - if (!progressDialog.opened) - Qt.callLater(progressDialog.open) + if (installDialog.opened) installDialog.close() + if (updateDialog.opened) updateDialog.close() + if (!progressDialog.opened) Qt.callLater(progressDialog.open) return } if (smartSession.firmwareInstallRequired && !ribbon.installPromptDeferred && @@ -70,15 +104,13 @@ SurfacePanel { } } function onFirmwareUpdateFinished(success) { - if (progressDialog.opened) - progressDialog.close() - + if (progressDialog.opened) progressDialog.close() ribbon.firmwareResultSuccess = success if (success) { ribbon.updatePromptDeferred = false ribbon.installPromptDeferred = false ribbon.firmwareResultTitle = "Firmware installed successfully" - ribbon.firmwareResultMessage = "ESP32-P4 restarted and ARStack firmware was verified. Studio is preparing the 4I+4V injector." + ribbon.firmwareResultMessage = "ESP32-P4 restarted, reconnected, and ARStack firmware was verified. Studio is preparing the 4I+4V injector." controller.showMessage("Firmware installed and verified successfully.", false) } else { ribbon.firmwareResultTitle = "Firmware installation not completed" @@ -94,8 +126,7 @@ SurfacePanel { Connections { target: device function onDeviceVerifiedChanged() { - if (!device.deviceVerified) - ribbon.updatePromptDeferred = false + if (!device.deviceVerified) ribbon.updatePromptDeferred = false } function onPortsChanged() { if (device.ports.length === 0) { @@ -104,15 +135,10 @@ SurfacePanel { } } function onProfileStateChanged() { - if (!ribbon.firmwareGateActive) - return + if (!ribbon.firmwareGateActive) return controller.profileDirty = true - // Main.qml also reacts to the same signal and may clear profileDirty - // when a legacy board reports PROFILE armed. Reassert the firmware - // gate after all synchronous signal handlers have completed. Qt.callLater(function() { - if (ribbon.firmwareGateActive) - controller.profileDirty = true + if (ribbon.firmwareGateActive) controller.profileDirty = true }) } } @@ -126,8 +152,8 @@ SurfacePanel { if (smartSession.state === "UPDATING FIRMWARE") return "Installing firmware" if (smartSession.state === "UPDATE NEEDS BOOT") return "Download mode required" if (smartSession.state === "PREPARING 4I+4V") return "Preparing" - if (smartSession.state === "READY") return "Ready" - if (smartSession.state === "RUNNING") return "Running" + if (smartSession.state === "READY") return "Ready to inject" + if (smartSession.state === "RUNNING") return "Injection running" if (smartSession.state === "PROFILE BLOCKED") return "Profile unavailable" if (smartSession.state === "SETUP ERROR") return "Setup issue" return smartSession.state @@ -149,12 +175,89 @@ SurfacePanel { if (smartSession.firmwareInstallRequired) return "Install ARStack firmware before starting injection." if (smartSession.firmwareUpdateRequired) return "Update firmware before starting injection." if (smartSession.updatingFirmware) return "Firmware installation is in progress." + if (smartSession.updateNeedsBootloaderHelp) return "Complete Download mode recovery before starting injection." if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE") return "Studio is identifying the device automatically." - if (smartSession.state === "PREPARING 4I+4V") return "Preparing the default 4I+4V injection automatically." + if (smartSession.state === "PREPARING 4I+4V") return "Studio is preparing the default 4I+4V injection automatically." if (smartSession.state === "WAITING FOR DEVICE") return "Connect ESP32-P4; Studio will detect it automatically." return smartSession.statusText } + function requestStart() { + if (device.running) return + if (smartSession.firmwareInstallRequired) { + ribbon.installPromptDeferred = false + installDialog.open() + return + } + if (smartSession.firmwareUpdateRequired) { + ribbon.updatePromptDeferred = false + updateDialog.open() + return + } + if (smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) { + controller.showMessage(ribbon.startReason(), false) + return + } + if (!smartSession.startReady) { + controller.showMessage(ribbon.startReason(), false) + return + } + device.start() + } + + function requestStop() { + if (device.running) device.stop() + } + + function loadEngineeringFile(url) { + if (device.running) { + controller.showMessage("Stop injection before changing the engineering configuration.", true) + return false + } + if (!profiles.loadFile(url)) { + controller.showMessage(profiles.fatalError || "Unable to load engineering file.", true) + return false + } + operatorSettings.lastEngineeringUrl = url.toString() + controller.profileDirty = false + controller.showMessage(profiles.documentStatus, false) + return true + } + + function useBuiltInProfile() { + if (device.running) { + controller.showMessage("Stop injection before changing the engineering configuration.", true) + return + } + if (profiles.loadReferenceTemplate()) { + operatorSettings.lastEngineeringUrl = "" + controller.profileDirty = false + controller.showMessage("Built-in 4I+4V / 4000 fps reference profile selected.", false) + } else { + controller.showMessage(profiles.fatalError || "Unable to load the built-in profile.", true) + } + } + + function resetDockLayout() { + controller.phasorDetached = false + controller.waveformDetached = false + controller.phasorDockVisible = true + controller.waveformDockVisible = false + controller.telemetryDockVisible = true + controller.telemetryExpanded = false + controller.showMessage("Dock layout reset.", false) + } + + FileDialog { + id: engineeringFileDialog + title: "Open IEC 61850 engineering configuration" + nameFilters: [ + "IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", + "All files (*)" + ] + onAccepted: ribbon.loadEngineeringFile(selectedFile) + } + Dialog { id: installDialog modal: true @@ -162,14 +265,7 @@ SurfacePanel { width: 420 title: "Set up ESP32-P4" closePolicy: Popup.NoAutoClose - - background: Rectangle { - color: ribbon.theme.surface - radius: 10 - border.width: 1 - border.color: ribbon.theme.line - } - + background: Rectangle { color: ribbon.theme.surface; radius: 10; border.width: 1; border.color: ribbon.theme.line } contentItem: ColumnLayout { spacing: 12 Label { @@ -182,7 +278,7 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "ARStack firmware is not installed or is not responding on this board. Studio can install the correct firmware automatically." + text: "ARStack firmware is not installed or is not responding. Studio can install the correct firmware automatically." color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 @@ -190,7 +286,7 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "No firmware file, ESP-IDF, Python, or command line is required. Keep the USB cable connected until Studio confirms success." + text: "No firmware file, ESP-IDF, Python, or command line is required. Keep USB connected until Studio confirms success." color: ribbon.theme.muted font.family: ribbon.uiFont font.pixelSize: 10 @@ -200,27 +296,16 @@ SurfacePanel { Layout.fillWidth: true Item { Layout.fillWidth: true } CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Later" - onClicked: { - ribbon.installPromptDeferred = true - installDialog.close() - } + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Later" + onClicked: { ribbon.installPromptDeferred = true; installDialog.close() } } CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Install firmware" - tone: "accent" - implicitWidth: 142 + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Install firmware"; tone: "accent"; implicitWidth: 142 onClicked: { if (smartSession.beginFirmwareInstall()) { ribbon.installPromptDeferred = false installDialog.close() - } else { - controller.showMessage("Firmware installation could not start. Open Advanced for recovery details.", true) - } + } else controller.showMessage("Firmware installation could not start. Open Tools > Advanced for recovery details.", true) } } } @@ -231,31 +316,24 @@ SurfacePanel { id: updateDialog modal: true anchors.centerIn: Overlay.overlay - width: 400 - title: "Firmware update" + width: 410 + title: "Firmware update required" closePolicy: Popup.NoAutoClose - - background: Rectangle { - color: ribbon.theme.surface - radius: 10 - border.width: 1 - border.color: ribbon.theme.line - } - + background: Rectangle { color: ribbon.theme.surface; radius: 10; border.width: 1; border.color: ribbon.theme.line } contentItem: ColumnLayout { spacing: 12 Label { Layout.fillWidth: true - text: "A newer ARStack firmware is included with this Studio build." + text: "Update firmware to start injection" color: ribbon.theme.text font.family: ribbon.uiFont - font.pixelSize: 13 + font.pixelSize: 14 font.weight: Font.DemiBold wrapMode: Text.WordWrap } Label { Layout.fillWidth: true - text: "Update now? Studio will install it, restart the board, reconnect, and verify the result automatically." + text: "This ESP32-P4 is connected, but its ARStack firmware is older than this Studio build. Update now? Studio will flash, restart, reconnect, and verify it automatically." color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 @@ -265,27 +343,16 @@ SurfacePanel { Layout.fillWidth: true Item { Layout.fillWidth: true } CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Later" - onClicked: { - ribbon.updatePromptDeferred = true - updateDialog.close() - } + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Later" + onClicked: { ribbon.updatePromptDeferred = true; updateDialog.close() } } CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Update" - tone: "accent" - implicitWidth: 110 + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Update firmware"; tone: "accent"; implicitWidth: 138 onClicked: { if (smartSession.beginFirmwareUpdate()) { ribbon.updatePromptDeferred = false updateDialog.close() - } else { - controller.showMessage("Firmware update could not start. Open Advanced for recovery help.", true) - } + } else controller.showMessage("Firmware update could not start. Open Tools > Advanced for recovery help.", true) } } } @@ -299,21 +366,19 @@ SurfacePanel { width: 450 title: smartSession.updateNeedsBootloaderHelp ? "Device needs Download mode" : "Installing firmware" closePolicy: Popup.NoAutoClose - background: Rectangle { color: ribbon.theme.surface radius: 10 border.width: 1 border.color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.line } - contentItem: ColumnLayout { spacing: 12 Label { Layout.fillWidth: true text: smartSession.updateNeedsBootloaderHelp ? "Studio cannot reach the ESP32-P4 bootloader yet." - : "Keep the USB cable connected. Studio will verify the board before reporting success." + : "Keep USB connected. Studio verifies the board before reporting success." color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.text font.family: ribbon.uiFont font.pixelSize: 13 @@ -331,8 +396,7 @@ SurfacePanel { ProgressBar { visible: !smartSession.updateNeedsBootloaderHelp Layout.fillWidth: true - from: 0 - to: 100 + from: 0; to: 100 indeterminate: smartSession.firmwareProgress < 0 value: smartSession.firmwareProgress < 0 ? 0 : smartSession.firmwareProgress } @@ -350,14 +414,7 @@ SurfacePanel { visible: smartSession.updateNeedsBootloaderHelp Layout.fillWidth: true Item { Layout.fillWidth: true } - CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Retry" - tone: "accent" - implicitWidth: 110 - onClicked: smartSession.retryFirmwareUpdate() - } + CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Retry"; tone: "accent"; implicitWidth: 110; onClicked: smartSession.retryFirmwareUpdate() } } } } @@ -369,14 +426,12 @@ SurfacePanel { width: 430 title: ribbon.firmwareResultSuccess ? "Success" : "Firmware result" closePolicy: Popup.NoAutoClose - background: Rectangle { color: ribbon.theme.surface radius: 10 border.width: 1 border.color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.red } - contentItem: ColumnLayout { spacing: 12 Label { @@ -410,227 +465,201 @@ SurfacePanel { RowLayout { Layout.fillWidth: true Item { Layout.fillWidth: true } - CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "OK" - tone: ribbon.firmwareResultSuccess ? "success" : "normal" - implicitWidth: 100 - onClicked: resultDialog.close() - } + CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "OK"; tone: ribbon.firmwareResultSuccess ? "success" : "normal"; implicitWidth: 100; onClicked: resultDialog.close() } Item { Layout.fillWidth: true } } } } - implicitHeight: 88 + implicitHeight: 72 color: theme.surface2 border.color: theme.line - component ActionButton: CalmButton { + component MenuButton: CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + implicitHeight: 24 + implicitWidth: 66 + font.pixelSize: 10 + } + + component RunButton: CalmButton { theme: ribbon.theme uiFont: ribbon.uiFont - implicitHeight: 40 + implicitHeight: 36 + implicitWidth: 112 iconSize: 15 font.pixelSize: 10 } ColumnLayout { anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - anchors.topMargin: 8 - anchors.bottomMargin: 8 - spacing: 6 + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 5 + anchors.bottomMargin: 5 + spacing: 4 RowLayout { Layout.fillWidth: true Layout.preferredHeight: 24 - spacing: 8 + spacing: 3 + + MenuButton { + text: "File" + onClicked: fileMenu.open() + Menu { + id: fileMenu + y: parent.height + MenuItem { text: "Open Engineering File…"; enabled: !device.running; onTriggered: engineeringFileDialog.open() } + MenuItem { + text: "Reopen Last Configuration" + enabled: !device.running && operatorSettings.lastEngineeringUrl.length > 0 + onTriggered: ribbon.loadEngineeringFile(operatorSettings.lastEngineeringUrl) + } + MenuSeparator {} + MenuItem { text: "Use Built-in 4I+4V Profile"; enabled: !device.running; onTriggered: ribbon.useBuiltInProfile() } + MenuSeparator {} + MenuItem { text: "Exit"; onTriggered: Qt.quit() } + } + } - Rectangle { - width: 8 - height: 8 - radius: 4 - color: ribbon.smartStateColor + MenuButton { + text: "Injection" + implicitWidth: 82 + onClicked: injectionMenu.open() + Menu { + id: injectionMenu + y: parent.height + MenuItem { text: "Start Injection F5"; enabled: !device.running; onTriggered: ribbon.requestStart() } + MenuItem { text: "Stop Injection F6"; enabled: device.running; onTriggered: ribbon.requestStop() } + MenuSeparator {} + MenuItem { text: "Balanced 3-Phase"; onTriggered: controller.balanced() } + MenuItem { text: "Zero All"; onTriggered: controller.zeroAll() } + } } - Label { - text: ribbon.displayState - color: ribbon.smartStateColor - font.family: ribbon.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold + + MenuButton { + text: "View" + onClicked: viewMenu.open() + Menu { + id: viewMenu + y: parent.height + Menu { + title: "Docks" + MenuItem { + text: (controller.phasorDockVisible || controller.phasorDetached ? "✓ " : " ") + "Phasor" + onTriggered: { + if (controller.phasorDetached) controller.phasorDetached = false + controller.phasorDockVisible = !controller.phasorDockVisible + } + } + MenuItem { + text: (controller.waveformDockVisible || controller.waveformDetached ? "✓ " : " ") + "Waveform" + onTriggered: { + if (controller.waveformDetached) controller.waveformDetached = false + controller.waveformDockVisible = !controller.waveformDockVisible + } + } + MenuItem { + text: (controller.telemetryDockVisible ? "✓ " : " ") + "Status Monitor" + onTriggered: controller.telemetryDockVisible = !controller.telemetryDockVisible + } + MenuSeparator {} + MenuItem { + text: "Dock All Floating Views" + enabled: controller.phasorDetached || controller.waveformDetached + onTriggered: { + if (controller.phasorDetached) { controller.phasorDetached = false; controller.phasorDockVisible = true } + if (controller.waveformDetached) { controller.waveformDetached = false; controller.waveformDockVisible = true } + } + } + MenuItem { text: "Reset Dock Layout"; onTriggered: ribbon.resetDockLayout() } + } + } } - Label { - visible: !ribbon.compact && smartSession.state !== "READY" && smartSession.state !== "RUNNING" - text: smartSession.statusText - color: ribbon.theme.muted - font.family: ribbon.uiFont - font.pixelSize: 10 - elide: Text.ElideRight - Layout.fillWidth: true + + MenuButton { + text: "Tools" + onClicked: toolsMenu.open() + Menu { + id: toolsMenu + y: parent.height + MenuItem { text: "Advanced…"; onTriggered: controller.openConfiguration() } + MenuItem { text: "Diagnostics…"; onTriggered: controller.openDiagnostics() } + } } + + Item { Layout.fillWidth: true } + Label { - visible: !ribbon.compact && (smartSession.state === "READY" || smartSession.state === "RUNNING") - text: smartSession.state === "RUNNING" - ? "4I + 4V · 4000 samples/s · live" - : "4I + 4V · 4000 samples/s" + visible: !ribbon.compact + text: operatorSettings.lastEngineeringUrl.length > 0 ? "Last configuration auto-opens" : "Built-in 4I+4V default" color: ribbon.theme.muted font.family: ribbon.uiFont - font.pixelSize: 10 - Layout.fillWidth: true - } - Item { Layout.fillWidth: ribbon.compact } - CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - text: "Advanced" - implicitHeight: 28 - font.pixelSize: 10 - toolTipText: "Firmware, engineering profile, waveform and timing tools" - onClicked: ribbon.controller.openConfiguration() + font.pixelSize: 9 } } RowLayout { Layout.fillWidth: true Layout.fillHeight: true - spacing: 6 - - ActionButton { - visible: !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp - text: "Balanced" - iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") - toolTipText: "Apply balanced three-phase values" - onClicked: ribbon.controller.balanced() - } - ActionButton { - visible: !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp - text: "Zero" - iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") - toolTipText: "Set all current and voltage magnitudes to zero" - onClicked: ribbon.controller.zeroAll() - } - - Rectangle { - visible: !ribbon.compact && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp - width: 1 - height: 28 - color: ribbon.theme.lineSoft - } - - ActionButton { - visible: !ribbon.compact && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp - text: "Phasor" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") - tone: ribbon.controller.phasorDockVisible || ribbon.controller.phasorDetached ? "accent" : "neutral" - onClicked: { - ribbon.controller.phasorDetached = false - ribbon.controller.phasorDockVisible = !ribbon.controller.phasorDockVisible - } - } - ActionButton { - visible: !ribbon.compact && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp - text: "Waveform" - iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") - tone: ribbon.controller.waveformDockVisible || ribbon.controller.waveformDetached ? "accent" : "neutral" - onClicked: { - ribbon.controller.waveformDetached = false - ribbon.controller.waveformDockVisible = !ribbon.controller.waveformDockVisible - } - } - - Item { Layout.fillWidth: true } - - RowLayout { - visible: smartSession.updatingFirmware - spacing: 8 - Label { - visible: !ribbon.compact - text: "Installing firmware" - color: ribbon.theme.textSoft - font.family: ribbon.uiFont - font.pixelSize: 10 - } - ProgressBar { - indeterminate: smartSession.firmwareProgress < 0 - from: 0 - to: 100 - value: smartSession.firmwareProgress < 0 ? 0 : smartSession.firmwareProgress - Layout.preferredWidth: ribbon.compact ? 125 : 190 - } - Label { - visible: smartSession.firmwareProgress >= 0 - text: smartSession.firmwareProgress + "%" - color: ribbon.theme.textSoft - font.family: ribbon.monoFont - font.pixelSize: 10 - font.weight: Font.DemiBold - Layout.preferredWidth: 36 - horizontalAlignment: Text.AlignRight - } - } + spacing: 8 + Rectangle { width: 8; height: 8; radius: 4; color: ribbon.smartStateColor } Label { - visible: smartSession.updateNeedsBootloaderHelp - text: ribbon.compact ? "BOOT + RESET" : "Put the board in Download mode" - color: ribbon.theme.amber + text: ribbon.displayState + color: ribbon.smartStateColor font.family: ribbon.uiFont - font.pixelSize: 10 + font.pixelSize: 11 font.weight: Font.DemiBold } - ActionButton { - visible: smartSession.updateNeedsBootloaderHelp - text: "Retry" - tone: "accent" - implicitWidth: 96 - toolTipText: smartSession.updateStatus - onClicked: smartSession.retryFirmwareUpdate() + Label { + Layout.fillWidth: true + visible: !ribbon.compact + text: smartSession.state === "READY" ? "4I + 4V · 4000 samples/s" : smartSession.statusText + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 10 + elide: Text.ElideRight } - ActionButton { - visible: smartSession.firmwareInstallRequired && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp - text: "Install firmware" + CalmButton { + visible: smartSession.firmwareUpdateRequired && !smartSession.updatingFirmware + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Update firmware" tone: "accent" - implicitWidth: 142 - onClicked: { - ribbon.installPromptDeferred = false - installDialog.open() - } + implicitHeight: 32 + implicitWidth: 128 + onClicked: { ribbon.updatePromptDeferred = false; updateDialog.open() } } - - ActionButton { - visible: smartSession.firmwareUpdateRequired && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp - text: "Update firmware" + CalmButton { + visible: smartSession.firmwareInstallRequired && !smartSession.updatingFirmware + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Install firmware" tone: "accent" - implicitWidth: 136 - onClicked: { - ribbon.updatePromptDeferred = false - updateDialog.open() - } + implicitHeight: 32 + implicitWidth: 128 + onClicked: { ribbon.installPromptDeferred = false; installDialog.open() } } - ActionButton { - // Keep the primary run affordance stable even when firmware gates execution. - // Safety still comes from startReady=false; the tooltip explains the blocker. - visible: !device.running && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp + RunButton { text: "Start" iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") - tone: "success" - implicitWidth: 140 - enabled: smartSession.startReady + tone: smartSession.startReady ? "success" : "neutral" + enabled: !device.running && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp toolTipText: ribbon.startReason() - onClicked: device.start() + onClicked: ribbon.requestStart() } - ActionButton { - visible: device.running && !smartSession.updatingFirmware + RunButton { text: "Stop" iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") - tone: "danger" - implicitWidth: 140 - enabled: device.deviceVerified - toolTipText: "Stop Sampled Values output" - onClicked: device.stop() + tone: device.running ? "danger" : "neutral" + enabled: device.running && !smartSession.updatingFirmware + toolTipText: device.running ? "Stop Sampled Values output" : "Injection is not running" + onClicked: ribbon.requestStop() } } } From abe814b907e03e9d43d94ad2ac31502e699b27ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:19:03 +0700 Subject: [PATCH 126/272] feat(studio): add OBS-style dock title interactions --- apps/arstack_studio/qml/DockFrame.qml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/DockFrame.qml b/apps/arstack_studio/qml/DockFrame.qml index ce34e8976..c559e5ca9 100644 --- a/apps/arstack_studio/qml/DockFrame.qml +++ b/apps/arstack_studio/qml/DockFrame.qml @@ -33,6 +33,14 @@ Rectangle { Layout.preferredHeight: 34 color: "#131b24" + MouseArea { + anchors.fill: parent + enabled: frame.detachable + acceptedButtons: Qt.LeftButton + cursorShape: frame.detachable ? Qt.SizeAllCursor : Qt.ArrowCursor + onDoubleClicked: frame.detachRequested() + } + RowLayout { anchors.fill: parent anchors.leftMargin: 10 @@ -85,7 +93,7 @@ Rectangle { uiFont: frame.uiFont onClicked: frame.detachRequested() ToolTip.visible: hovered - ToolTip.text: "Detach into a separate window" + ToolTip.text: "Float this dock (double-click the title also works)" } DarkToolButton { visible: frame.closable @@ -95,7 +103,7 @@ Rectangle { uiFont: frame.uiFont onClicked: frame.closeRequested() ToolTip.visible: hovered - ToolTip.text: "Close this view" + ToolTip.text: "Hide this dock; restore it from View > Docks" } } From e7ef9c22749042807cf34ff6eebb1cd1996c7de4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:20:30 +0700 Subject: [PATCH 127/272] feat(studio): unify operator state and simplify main chrome --- apps/arstack_studio/qml/Main.qml | 723 +++++++++++++------------------ 1 file changed, 296 insertions(+), 427 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 31d6d77d2..71d481100 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -61,35 +61,6 @@ ApplicationWindow { readonly property bool canStart: device.deviceVerified && !device.running && (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) - readonly property string instrumentState: { - if (device.discovering || (device.connected && !device.deviceVerified)) return "VERIFYING" - if (!device.connected) return "OFFLINE" - if (device.profileDeploying) return "DEPLOYING" - if (device.running) return "RUNNING" - if (profileDirty) return "PROFILE CHANGED" - if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "PROFILE BLOCKED" - if (sclProfiles.hasProfiles && !device.profileArmed) return "PROFILE VALIDATED" - if (device.profileArmed) return "ARMED" - return "CONNECTED" - } - readonly property color instrumentStateColor: { - if (instrumentState === "RUNNING") return studioTheme.green - if (instrumentState === "ARMED") return studioTheme.accent - if (instrumentState === "VERIFYING" || instrumentState === "DEPLOYING" || instrumentState === "PROFILE CHANGED") return studioTheme.amber - if (instrumentState === "PROFILE BLOCKED") return studioTheme.red - return device.connected ? studioTheme.textSoft : studioTheme.muted - } - readonly property string stateReason: { - if (device.discovering || (device.connected && !device.deviceVerified)) return device.discoveryStatus - if (!device.connected) return device.discoveryStatus - if (device.profileDeploying) return "Committing immutable SCL profile to the device." - if (device.running) return "Live apply active · valid edits update SMV immediately." - if (profileDirty) return "Selected profile changed · deploy before Start." - if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "Selected stream is outside the current ESP32-P4 deployment boundary." - if (sclProfiles.hasProfiles && !device.profileArmed) return "Profile validated · deploy to arm the injector." - if (device.profileArmed) return "Ready · live setpoint editing is armed." - return "Connected · development setpoints ready." - } readonly property string toastMessage: transientMessage.length ? transientMessage : device.lastError readonly property bool toastError: transientMessage.length ? transientError : device.lastError.length > 0 @@ -105,10 +76,6 @@ ApplicationWindow { function onDeviceMessage(message) { root.showMessage(message, false) } - function onDeviceVerifiedChanged() { - if (device.deviceVerified) - Qt.callLater(root.applyAllSignals) - } } Timer { @@ -145,7 +112,7 @@ ApplicationWindow { ListModel { id: statusHistoryModel - ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready. Edit locally or connect an injector."; isError: false } + ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready."; isError: false } } function showMessage(message, error) { @@ -194,38 +161,6 @@ ApplicationWindow { profileDirty = true } - function runReadinessCheck() { - if (sclProfiles.fatalError.length) { - showMessage(sclProfiles.fatalError, true) - return - } - if (sclProfiles.hasProfiles && !selectedProfileDeployable) { - showMessage("Selected stream is valid SCL but outside the current ESP32-P4 deployment boundary.", true) - return - } - if (!device.deviceVerified) { - showMessage(sclProfiles.hasProfiles ? - "Profile is valid. Waiting for injector recognition before deployment." : - "Development setpoints are ready. Waiting for injector recognition.", false) - return - } - if (sclProfiles.hasProfiles && (profileDirty || !device.profileArmed)) { - showMessage("Profile is valid and the device is connected. Deploy to arm output.", false) - return - } - showMessage(device.running ? - "Output is running and live apply is active." : - "Ready to start. Device and output state are consistent.", false) - } - - Shortcut { sequence: "Ctrl+O"; onActivated: root.openEngineeringFile() } - Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() } - Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() } - Shortcut { sequence: "Ctrl+K"; onActivated: root.runReadinessCheck() } - Shortcut { sequence: "Ctrl+D"; onActivated: root.deploySelectedProfile() } - Shortcut { sequence: "F5"; enabled: root.canStart; onActivated: device.start() } - Shortcut { sequence: "F6"; enabled: device.connected && device.running; onActivated: device.stop() } - function groupModel(group) { return group === 0 ? currentModel : voltageModel } @@ -278,14 +213,10 @@ ApplicationWindow { } function refreshPreview() { - if (previewPanel) - previewPanel.requestPaint() - if (waveformPanel) - waveformPanel.requestPaint() - if (detachedPhasorPanel) - detachedPhasorPanel.requestPaint() - if (detachedWaveformPanel) - detachedWaveformPanel.requestPaint() + if (previewPanel) previewPanel.requestPaint() + if (waveformPanel) waveformPanel.requestPaint() + if (detachedPhasorPanel) detachedPhasorPanel.requestPaint() + if (detachedWaveformPanel) detachedWaveformPanel.requestPaint() } function selectSignal(group, row) { @@ -303,31 +234,20 @@ ApplicationWindow { function focusCell(group, row, column) { var nextGroup = group var nextRow = row - if (nextRow < 0) { - nextGroup = (nextGroup + 1) % 2 - nextRow = 3 - } - if (nextRow > 3) { - nextGroup = (nextGroup + 1) % 2 - nextRow = 0 - } + if (nextRow < 0) { nextGroup = (nextGroup + 1) % 2; nextRow = 3 } + if (nextRow > 3) { nextGroup = (nextGroup + 1) % 2; nextRow = 0 } groupMatrix(nextGroup).focusCell(nextRow, column) } function navigate(group, row, column, key) { - if (key === Qt.Key_Up) - focusCell(group, row - 1, column) - else if (key === Qt.Key_Down) - focusCell(group, row + 1, column) - else if (key === Qt.Key_Left) - column === 1 ? focusCell(group, row, 0) : focusCell(group, row - 1, 1) - else if (key === Qt.Key_Right) - column === 0 ? focusCell(group, row, 1) : focusCell(group, row + 1, 0) + if (key === Qt.Key_Up) focusCell(group, row - 1, column) + else if (key === Qt.Key_Down) focusCell(group, row + 1, column) + else if (key === Qt.Key_Left) column === 1 ? focusCell(group, row, 0) : focusCell(group, row - 1, 1) + else if (key === Qt.Key_Right) column === 0 ? focusCell(group, row, 1) : focusCell(group, row + 1, 0) } function sendSignal(group, row) { - if (!device.deviceVerified) - return true + if (!device.deviceVerified) return true var signal = groupModel(group).get(row) return device.setSignal( signal.signalId, @@ -340,14 +260,12 @@ ApplicationWindow { function sendLinkedGroup(group) { var ok = true - for (var i = 0; i < 3; ++i) - ok = sendSignal(group, i) && ok + for (var i = 0; i < 3; ++i) ok = sendSignal(group, i) && ok return ok } function applyGroupSignals(group) { - for (var row = 0; row < 4; ++row) - sendSignal(group, row) + for (var row = 0; row < 4; ++row) sendSignal(group, row) } function editSignal(group, row, field, value) { @@ -360,8 +278,7 @@ ApplicationWindow { if (phaseLink && row < 3) { if (field === "magnitude") { - for (var i = 0; i < 3; ++i) - model.setProperty(i, "magnitude", value) + for (var i = 0; i < 3; ++i) model.setProperty(i, "magnitude", value) } else { var base = value if (row === 1) base += 120 @@ -374,44 +291,35 @@ ApplicationWindow { selectSignal(group, row) if (device.deviceVerified) { - if (phaseLink && row < 3) - sendLinkedGroup(group) - else - sendSignal(group, row) + if (phaseLink && row < 3) sendLinkedGroup(group) + else sendSignal(group, row) } return true } function applyAllSignals() { - if (!device.deviceVerified) - return + if (!device.deviceVerified) return device.setFrequency(signalFrequency) - for (var group = 0; group < 2; ++group) - applyGroupSignals(group) + for (var group = 0; group < 2; ++group) applyGroupSignals(group) device.setCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) } function setFrequencyValue(value) { - if (!validFrequency(value)) - return false + if (!validFrequency(value)) return false signalFrequency = value - if (value > 0) - previousAcFrequency = value + if (value > 0) previousAcFrequency = value frequencyField.text = value.toFixed(3) frequencyField.invalidInput = false refreshPreview() - if (device.deviceVerified) - device.setFrequency(value) + if (device.deviceVerified) device.setFrequency(value) return true } function setWaveformMode(mode) { if (mode === "DC") { - if (ctSaturationEnabled) - setCtSaturation(false) - if (signalFrequency > 0) - previousAcFrequency = signalFrequency + if (ctSaturationEnabled) setCtSaturation(false) + if (signalFrequency > 0) previousAcFrequency = signalFrequency setFrequencyValue(0) showMessage("DC mode selected. Magnitude is an instantaneous signed value; phase is not used.", false) } else { @@ -448,8 +356,7 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (device.deviceVerified) - applyAllSignals() + if (device.deviceVerified) applyAllSignals() } function zeroAll() { @@ -459,18 +366,21 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (device.deviceVerified) - device.zero() + if (device.deviceVerified) device.zero() } function setActiveQuality(value) { var unsignedValue = Number(value) >>> 0 groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue - if (device.deviceVerified) - device.setQuality(activeSignal, unsignedValue) + if (device.deviceVerified) device.setQuality(activeSignal, unsignedValue) } + Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() } + Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() } + Shortcut { sequence: "F5"; enabled: !device.running; onActivated: workflowBar.requestStart() } + Shortcut { sequence: "F6"; enabled: device.running; onActivated: workflowBar.requestStop() } + Dialog { id: diagnosticsDialog width: Math.min(root.width * 0.72, 920) @@ -554,31 +464,29 @@ ApplicationWindow { Item { Layout.fillWidth: true } Rectangle { - Layout.preferredWidth: root.compactLayout ? 160 : 190 + Layout.preferredWidth: root.compactLayout ? 170 : 210 implicitHeight: 32 radius: 7 color: studioTheme.surface2 border.width: 1 - border.color: device.deviceVerified ? "#2c674e" : (device.discovering ? "#705827" : studioTheme.lineSoft) + border.color: workflowBar.smartStateColor RowLayout { anchors.fill: parent anchors.leftMargin: 10 anchors.rightMargin: 10 spacing: 8 - Rectangle { - width: 8; height: 8; radius: 4 - color: device.deviceVerified ? studioTheme.green : (device.discovering ? studioTheme.amber : studioTheme.muted2) - } + Rectangle { width: 8; height: 8; radius: 4; color: workflowBar.smartStateColor } Label { Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter - text: device.deviceVerified ? "Injector ready" : (device.discovering ? "Recognizing..." : "Injector offline") - color: device.deviceVerified ? studioTheme.green : studioTheme.textSoft + text: workflowBar.displayState + color: workflowBar.smartStateColor font.family: root.uiFont font.pixelSize: 9 font.weight: Font.DemiBold verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight } Label { text: device.deviceVerified ? "•••" : "↻" @@ -598,16 +506,14 @@ ApplicationWindow { onClicked: device.deviceVerified ? root.openConfiguration() : device.autoDetectAndConnect() } ToolTip.visible: identityMouse.containsMouse - ToolTip.text: device.deviceVerified - ? "ARStack ESP32-P4 · ID " + device.deviceId.slice(-6) + " · Protocol v" + device.protocolVersion - : device.discoveryStatus + ToolTip.text: workflowBar.session.statusText ToolTip.delay: 450 } } } footer: Rectangle { - height: 48 + height: 30 color: studioTheme.chrome border.width: 1 border.color: studioTheme.lineSoft @@ -617,34 +523,22 @@ ApplicationWindow { anchors.leftMargin: 15 anchors.rightMargin: 15 spacing: 9 - - StateBadge { theme: studioTheme; state: root.instrumentState; stateColor: root.instrumentStateColor; monoFont: root.monoFont } - + Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor } Label { - Layout.maximumWidth: root.compactLayout ? 320 : 520 - text: root.stateReason + Layout.fillWidth: true + text: workflowBar.session.statusText color: studioTheme.muted font.family: root.uiFont font.pixelSize: studioTheme.captionSize elide: Text.ElideRight } - - Item { Layout.fillWidth: true } - Label { - visible: !root.compactLayout - text: "Ctrl+O source · Ctrl+K check · F5 start · F6 stop" - color: studioTheme.muted - font.family: root.uiFont + visible: device.running + text: "FPS " + device.fps + " · MISSED " + device.missed + " · TX FAIL " + device.txFailures + color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft + font.family: root.monoFont font.pixelSize: studioTheme.captionSize } - - CalmButton { - theme: studioTheme - uiFont: root.uiFont - text: "Diagnostics" - onClicked: diagnosticsDialog.open() - } } } @@ -655,7 +549,7 @@ ApplicationWindow { minimumWidth: 640 minimumHeight: 660 visible: false - title: "ARStack Studio · Configuration" + title: "ARStack Studio · Advanced" color: studioTheme.bg onClosing: function(close) { @@ -667,8 +561,8 @@ ApplicationWindow { anchors.fill: parent anchors.margins: 10 theme: studioTheme - titleText: "Smart & Expert Configuration" - statusText: device.deviceVerified ? "ESP32-P4 VERIFIED" : "OFFLINE EDIT" + titleText: "Advanced" + statusText: device.deviceVerified ? "DEVICE CONNECTED" : "OFFLINE" uiFont: root.uiFont monoFont: root.monoFont closable: false @@ -693,20 +587,17 @@ ApplicationWindow { minimumWidth: 400 minimumHeight: 400 visible: root.phasorDetached - title: "ARStack Studio · Phasor View" + title: "ARStack Studio · Phasor" color: studioTheme.bg - onClosing: function(close) { close.accepted = false root.phasorDetached = false root.phasorDockVisible = true } - Rectangle { anchors.fill: parent anchors.margins: 10 color: studioTheme.bg - SignalPreview { id: detachedPhasorPanel anchors.fill: parent @@ -738,20 +629,17 @@ ApplicationWindow { minimumWidth: 480 minimumHeight: 340 visible: root.waveformDetached - title: "ARStack Studio · Waveform View" + title: "ARStack Studio · Waveform" color: studioTheme.bg - onClosing: function(close) { close.accepted = false root.waveformDetached = false root.waveformDockVisible = true } - Rectangle { anchors.fill: parent anchors.margins: 10 color: studioTheme.bg - SignalPreview { id: detachedWaveformPanel anchors.fill: parent @@ -782,9 +670,10 @@ ApplicationWindow { spacing: root.compactLayout ? 9 : 11 WorkflowBar { + id: workflowBar Layout.fillWidth: true - Layout.preferredHeight: 94 - Layout.minimumHeight: 94 + Layout.preferredHeight: 72 + Layout.minimumHeight: 72 theme: studioTheme controller: root device: device @@ -831,309 +720,289 @@ ApplicationWindow { HoverHandler { cursorShape: Qt.SplitHCursor } } - SurfacePanel { - SplitView.fillWidth: true - SplitView.fillHeight: true - SplitView.minimumWidth: root.compactLayout ? 500 : 550 - SplitView.preferredWidth: root.compactLayout ? 700 : 860 - theme: studioTheme - - ColumnLayout { - anchors.fill: parent - anchors.margins: root.compactLayout ? 12 : 15 - spacing: 9 + SurfacePanel { + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumWidth: root.compactLayout ? 500 : 550 + SplitView.preferredWidth: root.compactLayout ? 700 : 860 + theme: studioTheme - RowLayout { - Layout.fillWidth: true ColumnLayout { - spacing: 1 - Label { text: "INJECTION WORKSPACE"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } - Label { text: "Manual Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } - } - Item { Layout.fillWidth: true } - } - - Rectangle { - Layout.fillWidth: true - height: 53 - radius: 7 - color: studioTheme.surface2 - border.width: 1 - border.color: studioTheme.lineSoft - - RowLayout { anchors.fill: parent - anchors.leftMargin: 11 - anchors.rightMargin: 11 - spacing: 8 - - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "AC"; tone: root.signalFrequency > 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("AC") } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "DC"; tone: root.signalFrequency === 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("DC") } + anchors.margins: root.compactLayout ? 12 : 15 + spacing: 9 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "INJECTION"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + } - Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + Rectangle { + Layout.fillWidth: true + height: 53 + radius: 7 + color: studioTheme.surface2 + border.width: 1 + border.color: studioTheme.lineSoft - ColumnLayout { - spacing: 0 - Label { text: root.signalFrequency === 0 ? "DC MODE" : "EDITABLE FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } RowLayout { - spacing: 5 - NumericField { - id: frequencyField - theme: studioTheme - monoFont: root.monoFont - compact: root.compactLayout - implicitWidth: 84 - text: "50.000" - suffixText: "Hz" - enabled: root.signalFrequency > 0 - validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } - onTextEdited: { - var value = root.parseOperatorNumber(text) - if (root.validFrequency(value)) { - invalidInput = false - root.signalFrequency = value - if (value > 0) root.previousAcFrequency = value - root.refreshPreview() - if (device.deviceVerified) device.setFrequency(value) - } else { - invalidInput = true - } - } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (!root.validFrequency(value)) { - text = root.signalFrequency.toFixed(3) - invalidInput = false - root.showMessage("AC frequency must be greater than 0 and not exceed 1000 Hz.", true) - } else { - text = value.toFixed(3) + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 11 + spacing: 8 + + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "AC"; tone: root.signalFrequency > 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("AC") } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "DC"; tone: root.signalFrequency === 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("DC") } + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + + ColumnLayout { + spacing: 0 + Label { text: root.signalFrequency === 0 ? "DC MODE" : "FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + RowLayout { + spacing: 5 + NumericField { + id: frequencyField + theme: studioTheme + monoFont: root.monoFont + compact: root.compactLayout + implicitWidth: 84 + text: "50.000" + suffixText: "Hz" + enabled: root.signalFrequency > 0 + validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } + onTextEdited: { + var value = root.parseOperatorNumber(text) + if (root.validFrequency(value)) { + invalidInput = false + root.signalFrequency = value + if (value > 0) root.previousAcFrequency = value + root.refreshPreview() + if (device.deviceVerified) device.setFrequency(value) + } else invalidInput = true + } + onEditingFinished: { + var value = root.parseOperatorNumber(text) + if (!root.validFrequency(value)) { + text = root.signalFrequency.toFixed(3) + invalidInput = false + root.showMessage("AC frequency must be greater than 0 and not exceed 1000 Hz.", true) + } else text = value.toFixed(3) + } } } } + + CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } + CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + CheckBox { + enabled: root.signalFrequency > 0 + checked: root.phaseLink + text: "3-phase link" + onToggled: root.phaseLink = checked + font.family: root.uiFont + font.pixelSize: 9 + } + Item { Layout.fillWidth: true } } } - CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } - CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } - - Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } - - CheckBox { - enabled: root.signalFrequency > 0 - checked: root.phaseLink - text: "3-phase link" - onToggled: root.phaseLink = checked - font.family: root.uiFont - font.pixelSize: 9 + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 9 + SignalMatrix { + id: currentMatrix + Layout.fillWidth: true + Layout.fillHeight: true + theme: studioTheme + controller: root + device: device + sourceModel: currentModel + uiFont: root.uiFont + monoFont: root.monoFont + groupIndex: 0 + titleText: "Current" + symbolText: "I" + unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" + compact: root.compactLayout + } + SignalMatrix { + id: voltageMatrix + Layout.fillWidth: true + Layout.fillHeight: true + theme: studioTheme + controller: root + device: device + sourceModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + groupIndex: 1 + titleText: "Voltage" + symbolText: "U" + unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" + compact: root.compactLayout + } } - Item { Layout.fillWidth: true } + Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } + Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: studioTheme.labelSize; font.weight: Font.Bold } + Label { + text: root.signalFrequency === 0 + ? root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " DC" + : root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + } + Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } + QualityEditor { + Layout.fillWidth: true + theme: studioTheme + controller: root + device: device + uiFont: root.uiFont + monoFont: root.monoFont + qualityValue: root.activeQuality + compact: root.compactLayout + } + } } } - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 9 - - SignalMatrix { - id: currentMatrix - Layout.fillWidth: true - Layout.fillHeight: true - theme: studioTheme - controller: root - device: device - sourceModel: currentModel - uiFont: root.uiFont - monoFont: root.monoFont - groupIndex: 0 - titleText: "Current" - symbolText: "I" - unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" - compact: root.compactLayout + SplitView { + visible: (root.phasorDockVisible && !root.phasorDetached) || + (root.waveformDockVisible && !root.waveformDetached) + SplitView.preferredWidth: root.compactLayout ? 300 : 380 + SplitView.minimumWidth: root.compactLayout ? 270 : 300 + SplitView.maximumWidth: 680 + SplitView.fillHeight: true + orientation: Qt.Vertical + handle: Rectangle { + implicitHeight: 9 + color: SplitHandle.pressed ? studioTheme.accentSoft + : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" + Rectangle { + anchors.centerIn: parent + width: parent.width + height: 1 + color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line + } + HoverHandler { cursorShape: Qt.SplitVCursor } } - SignalMatrix { - id: voltageMatrix - Layout.fillWidth: true - Layout.fillHeight: true + DockFrame { + visible: root.phasorDockVisible && !root.phasorDetached + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 170 + SplitView.preferredHeight: 300 theme: studioTheme - controller: root - device: device - sourceModel: voltageModel + titleText: "Phasor" + statusText: "GENERATED" uiFont: root.uiFont monoFont: root.monoFont - groupIndex: 1 - titleText: "Voltage" - symbolText: "U" - unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" - compact: root.compactLayout + detachable: true + onDetachRequested: root.detachPhasor() + onCloseRequested: root.phasorDockVisible = false + SignalPreview { + id: previewPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + viewMode: "phasor" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } } - } - - Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } - Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: studioTheme.labelSize; font.weight: Font.Bold } - Label { - text: root.signalFrequency === 0 - ? root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " DC" - : root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 9 - } - Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } - QualityEditor { - Layout.fillWidth: true + DockFrame { + visible: root.waveformDockVisible && !root.waveformDetached + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 170 + SplitView.preferredHeight: 300 theme: studioTheme - controller: root - device: device + titleText: "Waveform" + statusText: root.signalFrequency.toFixed(3) + " Hz" uiFont: root.uiFont monoFont: root.monoFont - qualityValue: root.activeQuality - compact: root.compactLayout - } - Label { - visible: !root.compactLayout - text: "↑↓ channel ←→ field" - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 8 + detachable: true + onDetachRequested: root.detachWaveform() + onCloseRequested: root.waveformDockVisible = false + SignalPreview { + id: waveformPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + viewMode: "waveform" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } } } } - } - SplitView { - visible: (root.phasorDockVisible && !root.phasorDetached) || - (root.waveformDockVisible && !root.waveformDetached) - SplitView.preferredWidth: root.compactLayout ? 300 : 380 - SplitView.minimumWidth: root.compactLayout ? 270 : 300 - SplitView.maximumWidth: 680 - SplitView.fillHeight: true - orientation: Qt.Vertical - handle: Rectangle { - implicitHeight: 9 - color: SplitHandle.pressed ? studioTheme.accentSoft - : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" - Rectangle { - anchors.centerIn: parent - width: parent.width - height: 1 - color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line - } - HoverHandler { cursorShape: Qt.SplitVCursor } - } - - DockFrame { - visible: root.phasorDockVisible && !root.phasorDetached + TelemetryDock { + visible: root.telemetryDockVisible SplitView.fillWidth: true - SplitView.fillHeight: true - SplitView.minimumHeight: 170 - SplitView.preferredHeight: 300 + SplitView.minimumHeight: root.telemetryExpanded ? 88 : 32 + SplitView.maximumHeight: root.telemetryExpanded ? 320 : 32 + SplitView.preferredHeight: root.telemetryExpanded ? 124 : 32 + Behavior on SplitView.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } theme: studioTheme - titleText: "Phasor View" - statusText: "GENERATED" - uiFont: root.uiFont - monoFont: root.monoFont - detachable: true - onDetachRequested: root.detachPhasor() - onCloseRequested: root.phasorDockVisible = false - - SignalPreview { - id: previewPanel - anchors.fill: parent - theme: studioTheme - currentModel: currentModel - voltageModel: voltageModel - uiFont: root.uiFont - monoFont: root.monoFont - compact: root.compactLayout - showHeader: false - viewMode: "phasor" - activeSignal: root.activeSignal - activeUnit: root.activeUnit - activeMagnitude: root.activeMagnitude - activePhase: root.activePhase - signalFrequency: root.signalFrequency - ctSaturationEnabled: root.ctSaturationEnabled - ctDcOffsetPercent: root.ctDcOffsetPercent - ctHarmonicPercent: root.ctHarmonicPercent - ctHarmonicOrder: root.ctHarmonicOrder - ctClipPercent: root.ctClipPercent - } - } - - DockFrame { - visible: root.waveformDockVisible && !root.waveformDetached - SplitView.fillWidth: true - SplitView.fillHeight: true - SplitView.minimumHeight: 170 - SplitView.preferredHeight: 300 - theme: studioTheme - titleText: "Waveform View" - statusText: root.signalFrequency.toFixed(3) + " Hz" + device: device + currentModel: currentModel + voltageModel: voltageModel + historyModel: statusHistoryModel uiFont: root.uiFont monoFont: root.monoFont - detachable: true - onDetachRequested: root.detachWaveform() - onCloseRequested: root.waveformDockVisible = false - - SignalPreview { - id: waveformPanel - anchors.fill: parent - theme: studioTheme - currentModel: currentModel - voltageModel: voltageModel - uiFont: root.uiFont - monoFont: root.monoFont - compact: root.compactLayout - showHeader: false - viewMode: "waveform" - activeSignal: root.activeSignal - activeUnit: root.activeUnit - activeMagnitude: root.activeMagnitude - activePhase: root.activePhase - signalFrequency: root.signalFrequency - ctSaturationEnabled: root.ctSaturationEnabled - ctDcOffsetPercent: root.ctDcOffsetPercent - ctHarmonicPercent: root.ctHarmonicPercent - ctHarmonicOrder: root.ctHarmonicOrder - ctClipPercent: root.ctClipPercent - } + expanded: root.telemetryExpanded + onExpandedChanged: root.telemetryExpanded = expanded + onCloseRequested: root.telemetryDockVisible = false } } - } - - TelemetryDock { - visible: root.telemetryDockVisible - SplitView.fillWidth: true - SplitView.minimumHeight: root.telemetryExpanded ? 88 : 32 - SplitView.maximumHeight: root.telemetryExpanded ? 320 : 32 - SplitView.preferredHeight: root.telemetryExpanded ? 124 : 32 - Behavior on SplitView.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } - theme: studioTheme - device: device - currentModel: currentModel - voltageModel: voltageModel - historyModel: statusHistoryModel - uiFont: root.uiFont - monoFont: root.monoFont - expanded: root.telemetryExpanded - onExpandedChanged: root.telemetryExpanded = expanded - onCloseRequested: root.telemetryDockVisible = false - } - } } StatusToast { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: 72 + anchors.bottomMargin: 48 theme: studioTheme uiFont: root.uiFont message: root.toastMessage From 312053ebfd3916a1b9f6b871399ebe5de16ab1a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:22:22 +0700 Subject: [PATCH 128/272] feat(studio): restore last engineering stream selection --- apps/arstack_studio/qml/WorkflowBar.qml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 31e2ce1b0..ab8f6d219 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -30,6 +30,7 @@ SurfacePanel { id: operatorSettings category: "operator-ui" property string lastEngineeringUrl: "" + property int lastSelectedIndex: 0 property bool phasorVisible: false property bool waveformVisible: false property bool telemetryVisible: true @@ -55,10 +56,12 @@ SurfacePanel { if (operatorSettings.lastEngineeringUrl.length > 0) { if (profiles.loadFile(operatorSettings.lastEngineeringUrl)) { + profiles.selectStream(operatorSettings.lastSelectedIndex) controller.profileDirty = false controller.showMessage("Last engineering configuration restored.", false) } else { operatorSettings.lastEngineeringUrl = "" + operatorSettings.lastSelectedIndex = 0 controller.showMessage("The previous engineering file is no longer available; using the built-in 4I+4V profile.", false) } } @@ -75,6 +78,14 @@ SurfacePanel { function onTelemetryExpandedChanged() { operatorSettings.telemetryExpanded = controller.telemetryExpanded } } + Connections { + target: profiles + function onSelectedIndexChanged() { + if (profiles.selectedIndex >= 0) + operatorSettings.lastSelectedIndex = profiles.selectedIndex + } + } + Connections { target: smartSession function onReadyForLiveApply() { @@ -82,8 +93,6 @@ SurfacePanel { controller.applyAllSignals() } function onStateChanged() { - // Main.qml still exposes legacy keyboard shortcuts. Keep those paths - // fail-closed while firmware is missing or outdated. if (ribbon.firmwareGateActive) controller.profileDirty = true @@ -209,6 +218,11 @@ SurfacePanel { if (device.running) device.stop() } + function openEngineeringDialog() { + if (!device.running) engineeringFileDialog.open() + else controller.showMessage("Stop injection before changing the engineering configuration.", true) + } + function loadEngineeringFile(url) { if (device.running) { controller.showMessage("Stop injection before changing the engineering configuration.", true) @@ -219,6 +233,7 @@ SurfacePanel { return false } operatorSettings.lastEngineeringUrl = url.toString() + operatorSettings.lastSelectedIndex = profiles.selectedIndex >= 0 ? profiles.selectedIndex : 0 controller.profileDirty = false controller.showMessage(profiles.documentStatus, false) return true @@ -231,6 +246,7 @@ SurfacePanel { } if (profiles.loadReferenceTemplate()) { operatorSettings.lastEngineeringUrl = "" + operatorSettings.lastSelectedIndex = 0 controller.profileDirty = false controller.showMessage("Built-in 4I+4V / 4000 fps reference profile selected.", false) } else { @@ -511,7 +527,7 @@ SurfacePanel { Menu { id: fileMenu y: parent.height - MenuItem { text: "Open Engineering File…"; enabled: !device.running; onTriggered: engineeringFileDialog.open() } + MenuItem { text: "Open Engineering File…"; enabled: !device.running; onTriggered: ribbon.openEngineeringDialog() } MenuItem { text: "Reopen Last Configuration" enabled: !device.running && operatorSettings.lastEngineeringUrl.length > 0 From 25eb3e64e89487c7318ce1fc4e9b7312c35ab801 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:31:27 +0700 Subject: [PATCH 129/272] fix(studio): make Smart Session sole startup discovery owner --- apps/arstack_studio/src/SmartSessionController.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 450c99618..dccd305d2 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -173,6 +173,16 @@ void SmartSessionController::start() { if (started_) return; started_ = true; if (profiles_ != nullptr) static_cast(ensureDefaultProfile()); + + // SmartSessionController is the single owner of automatic discovery. Do the + // initial probe immediately, then use the bounded periodic watchdog only + // while the device is genuinely offline. The QML shell must not run a + // second startup probe/timer in parallel. + if (device_ != nullptr && !device_->deviceVerified() && + !device_->discovering() && !device_->connected()) { + static_cast(device_->autoDetectAndConnect()); + } + discoveryTimer_.start(); reconcile(); maybeScheduleBlankBoardProbe(); From 27decc50cd1494f2531e14fc67d6ebeafaf4fa92 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:32:42 +0700 Subject: [PATCH 130/272] fix(studio): preserve safe shutdown and centralize shell ownership --- apps/arstack_studio/qml/Main.qml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 71d481100..d016f6974 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -47,6 +47,13 @@ ApplicationWindow { font.family: root.uiFont + // Graceful window close is still a safety boundary. The firmware session + // lease covers hard crashes; a normal close should explicitly request STOP. + onClosing: function(close) { + if (device.running) + device.stop() + } + FontLoader { id: interFont source: Qt.resolvedUrl("../assets/InterVariable.ttf") @@ -78,13 +85,6 @@ ApplicationWindow { } } - Timer { - interval: 650 - running: true - repeat: false - onTriggered: device.autoDetectAndConnect() - } - Timer { id: messageTimer interval: 2600 @@ -143,7 +143,7 @@ ApplicationWindow { } function openEngineeringFile() { - profilePanel.openEngineeringFile() + workflowBar.openEngineeringDialog() } function openConfiguration() { From 97fed1c740d9825f8e212490e5ec7f59fbee35d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 11:33:19 +0700 Subject: [PATCH 131/272] refactor(studio): route engineering file open through one authority --- apps/arstack_studio/qml/ProfileInspector.qml | 22 ++------------------ 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index 8c68de41e..8cba254c4 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -2,7 +2,6 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import QtQuick.Dialogs SurfacePanel { id: panel @@ -35,24 +34,7 @@ SurfacePanel { } function openEngineeringFile() { - engineeringFileDialog.open() - } - - FileDialog { - id: engineeringFileDialog - title: "Open IEC 61850 engineering file" - nameFilters: [ - "IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", - "All files (*)" - ] - onAccepted: { - if (panel.profiles.loadFile(selectedFile)) { - panel.controller.profileDirty = true - panel.controller.showMessage(panel.profiles.documentStatus, false) - } else { - panel.controller.showMessage(panel.profiles.fatalError || "Unable to load engineering file.", true) - } - } + panel.controller.openEngineeringFile() } ColumnLayout { @@ -117,7 +99,7 @@ SurfacePanel { uiFont: panel.uiFont text: "Open SCL / CID" enabled: !panel.device.running - onClicked: engineeringFileDialog.open() + onClicked: panel.controller.openEngineeringFile() } ColumnLayout { From ab8ae0b598a4b273ff3b0433a87db9f0dd2d5dcf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 13:10:51 +0700 Subject: [PATCH 132/272] fix(studio): bound smart discovery and allow unique-port recovery --- apps/arstack_studio/src/SmartSessionController.hpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 8b5414306..a4ad49300 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include class DeviceController; @@ -45,6 +46,14 @@ class SmartSessionController : public QObject { [[nodiscard]] QString expectedFirmwareVersion() const; [[nodiscard]] QString deviceFirmwareVersion() const; + // Recovery selection is intentionally conservative: prefer the device + // classifier's recommended port; otherwise a single visible serial port is + // safe to ROM-probe because FirmwareManager performs a read-only chip check + // and refuses to write until ESP32-P4 pre-v3 is proven and the user approves. + [[nodiscard]] static QString chooseRecoveryPort( + const QString& recommendedPort, + const QStringList& visiblePorts); + void setDevice(QObject* object); void setProfiles(QObject* object); void setFirmware(QObject* object); @@ -89,6 +98,7 @@ class SmartSessionController : public QObject { QString updatePort_; QString blankBoardPort_; QString blankProbeAttemptedPort_; + QString setupErrorStatus_; bool startReady_{false}; bool firmwareUpdateRequired_{false}; bool started_{false}; @@ -98,6 +108,7 @@ class SmartSessionController : public QObject { bool updateRequested_{false}; bool blankProbeInFlight_{false}; bool blankBoardDetected_{false}; + bool setupError_{false}; int updateReconnectAttempts_{0}; UpdateStage updateStage_{UpdateStage::idle}; -}; +}; \ No newline at end of file From 7a55e9f07fbe9730279b0184c5cea4aef3cdee77 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 13:12:07 +0700 Subject: [PATCH 133/272] fix(studio): recover legacy and blank boards from unique serial ports --- .../src/SmartSessionController.cpp | 103 ++++++++++++++---- 1 file changed, 80 insertions(+), 23 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index dccd305d2..0cd0bd3cf 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -20,11 +20,21 @@ namespace { constexpr int kMaxUpdateReconnectAttempts = 6; } +QString SmartSessionController::chooseRecoveryPort( + const QString& recommendedPort, + const QStringList& visiblePorts) { + const QString recommended = recommendedPort.trimmed(); + if (!recommended.isEmpty()) return recommended; + if (visiblePorts.size() != 1) return {}; + return visiblePorts.front().trimmed(); +} + SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { discoveryTimer_.setInterval(2500); discoveryTimer_.setSingleShot(false); connect(&discoveryTimer_, &QTimer::timeout, this, [this] { - if (!started_ || device_ == nullptr || updateRequested_ || blankProbeInFlight_ || blankBoardDetected_) return; + if (!started_ || device_ == nullptr || updateRequested_ || blankProbeInFlight_ || + blankBoardDetected_ || setupError_) return; if (firmware_ != nullptr && firmware_->busy()) return; if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { static_cast(device_->autoDetectAndConnect()); @@ -40,11 +50,12 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent connect(&blankProbeTimer_, &QTimer::timeout, this, [this] { if (!started_ || device_ == nullptr || firmware_ == nullptr || updateRequested_ || device_->deviceVerified() || device_->discovering() || device_->connected() || - firmware_->busy() || !firmware_->bundleReady() || blankBoardDetected_ || blankProbeInFlight_) { + firmware_->busy() || !firmware_->bundleReady() || blankBoardDetected_ || + blankProbeInFlight_ || setupError_) { return; } - const QString port = device_->recommendedPort().trimmed(); + const QString port = chooseRecoveryPort(device_->recommendedPort(), device_->ports()); if (port.isEmpty() || port == blankProbeAttemptedPort_) return; blankProbeAttemptedPort_ = port; @@ -52,12 +63,16 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent blankProbeInFlight_ = true; setPresentation( QStringLiteral("CHECKING DEVICE"), - QStringLiteral("Checking whether the connected ESP32-P4 needs ARStack firmware…"), + QStringLiteral("Checking the connected board and firmware state…"), false, false); if (!firmware_->probeTarget(port)) { blankProbeInFlight_ = false; + setupError_ = true; + setupErrorStatus_ = firmware_->status().isEmpty() + ? QStringLiteral("Studio could not start the firmware recovery check on %1.").arg(port) + : firmware_->status(); reconcile(); } }); @@ -173,19 +188,18 @@ void SmartSessionController::start() { if (started_) return; started_ = true; if (profiles_ != nullptr) static_cast(ensureDefaultProfile()); - - // SmartSessionController is the single owner of automatic discovery. Do the - // initial probe immediately, then use the bounded periodic watchdog only - // while the device is genuinely offline. The QML shell must not run a - // second startup probe/timer in parallel. - if (device_ != nullptr && !device_->deviceVerified() && - !device_->discovering() && !device_->connected()) { - static_cast(device_->autoDetectAndConnect()); - } - discoveryTimer_.start(); - reconcile(); - maybeScheduleBlankBoardProbe(); + + // Do not make the operator wait for the periodic hot-plug watchdog. The + // first identity attempt starts as soon as the QML component is complete. + QTimer::singleShot(0, this, [this] { + if (!started_ || device_ == nullptr || updateRequested_) return; + if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { + static_cast(device_->autoDetectAndConnect()); + } + reconcile(); + maybeScheduleBlankBoardProbe(); + }); } bool SmartSessionController::beginFirmwareUpdate() { @@ -215,6 +229,8 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { profileSyncInFlight_ = false; blankProbeTimer_.stop(); blankProbeInFlight_ = false; + setupError_ = false; + setupErrorStatus_.clear(); if (device_->running()) { updateStage_ = UpdateStage::stopping; @@ -310,6 +326,8 @@ void SmartSessionController::reconnectDeviceSignals() { clearBlankBoardContext(); } else if (!blankProbeAttemptedPort_.isEmpty() && !device_->ports().contains(blankProbeAttemptedPort_)) { blankProbeAttemptedPort_.clear(); + setupError_ = false; + setupErrorStatus_.clear(); } reconcile(); maybeScheduleBlankBoardProbe(); @@ -360,9 +378,29 @@ void SmartSessionController::reconnectFirmwareSignals() { } blankProbeInFlight_ = false; - blankBoardDetected_ = firmware_->targetVerified() && - firmware_->selectedPort() == blankBoardPort_; - if (!blankBoardDetected_) blankBoardPort_.clear(); + const bool samePort = firmware_->selectedPort() == blankBoardPort_; + if (samePort && firmware_->targetVerified()) { + // Read-only ROM identity proved a supported P4. The user still + // explicitly approves installation in the normal setup dialog. + blankBoardDetected_ = true; + setupError_ = false; + setupErrorStatus_.clear(); + } else if (samePort && firmware_->bootloaderHelpNeeded()) { + // A unique serial candidate exists but ROM did not answer yet. + // Treat this as a firmware-setup candidate, not as verified P4; + // beginFirmwareInstall() re-probes and cannot write until target + // verification succeeds. This keeps novice recovery actionable + // without weakening the flash safety boundary. + blankBoardDetected_ = true; + setupError_ = false; + setupErrorStatus_.clear(); + } else { + blankBoardDetected_ = false; + setupError_ = true; + setupErrorStatus_ = firmware_->status().isEmpty() + ? QStringLiteral("The connected serial device is not a supported ESP32-P4 target.") + : firmware_->status(); + } reconcile(); return; } @@ -412,6 +450,12 @@ void SmartSessionController::reconnectFirmwareSignals() { connect(firmware_, &FirmwareManager::operationFailed, this, [this](const QString&, const bool bootloaderHelpNeeded) { if (!updateRequested_) { + if (blankProbeInFlight_) { + blankProbeInFlight_ = false; + blankBoardDetected_ = bootloaderHelpNeeded; + setupError_ = !bootloaderHelpNeeded; + setupErrorStatus_ = firmware_ != nullptr ? firmware_->status() : QString{}; + } reconcile(); return; } @@ -455,12 +499,12 @@ void SmartSessionController::reconnectFirmwareSignals() { void SmartSessionController::maybeScheduleBlankBoardProbe() { if (!started_ || device_ == nullptr || firmware_ == nullptr || updateRequested_ || blankProbeInFlight_ || blankBoardDetected_ || blankProbeTimer_.isActive() || - device_->deviceVerified() || device_->discovering() || device_->connected() || + setupError_ || device_->deviceVerified() || device_->discovering() || device_->connected() || firmware_->busy() || !firmware_->bundleReady()) { return; } - const QString port = device_->recommendedPort().trimmed(); + const QString port = chooseRecoveryPort(device_->recommendedPort(), device_->ports()); if (port.isEmpty() || port == blankProbeAttemptedPort_) return; blankProbeTimer_.start(); } @@ -471,6 +515,8 @@ void SmartSessionController::clearBlankBoardContext() { blankBoardDetected_ = false; blankBoardPort_.clear(); blankProbeAttemptedPort_.clear(); + setupError_ = false; + setupErrorStatus_.clear(); } bool SmartSessionController::ensureDefaultProfile() { @@ -566,7 +612,7 @@ void SmartSessionController::reconcile() { if (blankProbeInFlight_) { setPresentation( QStringLiteral("CHECKING DEVICE"), - QStringLiteral("Checking the ESP32-P4 firmware state…"), + QStringLiteral("Checking the connected board and firmware state…"), false, false); return; @@ -575,13 +621,24 @@ void SmartSessionController::reconcile() { if (blankBoardDetected_) { setPresentation( QStringLiteral("FIRMWARE REQUIRED"), - QStringLiteral("ESP32-P4 detected on %1. ARStack firmware is not installed or is not responding.") + QStringLiteral("Firmware setup is required on %1. Studio will verify ESP32-P4 before writing anything.") .arg(blankBoardPort_), false, false); return; } + if (setupError_) { + setPresentation( + QStringLiteral("SETUP ERROR"), + setupErrorStatus_.isEmpty() + ? QStringLiteral("Studio could not identify a supported ESP32-P4 on the connected serial device.") + : setupErrorStatus_, + false, + false); + return; + } + maybeScheduleBlankBoardProbe(); setPresentation( device_->ports().isEmpty() ? QStringLiteral("WAITING FOR DEVICE") : QStringLiteral("DEVICE FOUND"), From 2a249aadf0095f176adc026cfa5c3d03d82255a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 13:12:50 +0700 Subject: [PATCH 134/272] test(studio): lock unique-port firmware recovery selection --- apps/arstack_studio/src/main.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 7a3686e87..7063134c6 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -94,11 +94,23 @@ int checkFirmwareContract(int argc, char* argv[]) { FirmwareManager::parseFlashProgress(QStringLiteral("no progress token")) == -1 && FirmwareManager::parseFlashProgress(QStringLiteral("Writing 104%")) == -1; + const bool recoverySelection = + SmartSessionController::chooseRecoveryPort( + QStringLiteral("COM7"), + {QStringLiteral("COM3"), QStringLiteral("COM7")}) == QStringLiteral("COM7") && + SmartSessionController::chooseRecoveryPort( + QString{}, + {QStringLiteral("COM3")}) == QStringLiteral("COM3") && + SmartSessionController::chooseRecoveryPort( + QString{}, + {QStringLiteral("COM3"), QStringLiteral("COM4")}).isEmpty(); + const bool valid = firmware.bundleReady() && firmware.flasherAvailable() && firmware.firmwareVersion() == QStringLiteral(ARSTACK_STUDIO_VERSION) && firmware.expectedProtocol() == QStringLiteral("1") && firmware.firmwareSha256().size() == 64 && - realEspflashFormat && dashedFormat && rejectsWrongChip && revisionPolicy && flashProgressFormat; + realEspflashFormat && dashedFormat && rejectsWrongChip && revisionPolicy && + flashProgressFormat && recoverySelection; if (!valid) { qCritical().noquote() << "Firmware bundle/probe contract: FAIL ·" @@ -107,11 +119,12 @@ int checkFirmwareContract(int argc, char* argv[]) { << "dashed-format=" << dashedFormat << "wrong-chip-rejected=" << rejectsWrongChip << "revision-policy=" << revisionPolicy - << "progress-format=" << flashProgressFormat; + << "progress-format=" << flashProgressFormat + << "recovery-selection=" << recoverySelection; return 4; } qInfo().noquote() - << "Firmware bundle/probe contract: PASS · target/revision/progress/hash policy locked ·" + << "Firmware bundle/probe contract: PASS · target/revision/progress/hash/recovery policy locked ·" << firmware.bundleStatus(); return 0; } From aef011ad4da6ec797e23d365207f28c989a0b56a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:04:10 +0700 Subject: [PATCH 135/272] fix(studio): harden firmware process lifecycle --- apps/arstack_studio/src/FirmwareManager.hpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 5fe049531..361a381ec 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -25,6 +25,7 @@ class FirmwareManager : public QObject { public: explicit FirmwareManager(QObject* parent = nullptr); + ~FirmwareManager() override; [[nodiscard]] bool bundleReady() const noexcept { return bundleReady_; } [[nodiscard]] bool flasherAvailable() const noexcept { return flasherAvailable_; } @@ -49,6 +50,7 @@ class FirmwareManager : public QObject { Q_INVOKABLE bool probeTarget(const QString& portName); Q_INVOKABLE bool installFirmware(const QString& portName); Q_INVOKABLE void cancel(); + Q_INVOKABLE void shutdown(); Q_INVOKABLE void clearLog(); signals: @@ -64,8 +66,10 @@ class FirmwareManager : public QObject { [[nodiscard]] QString flasherPath() const; bool loadManifest(); bool startEspflash(const QStringList& arguments, Operation operation); + void startOperationDeadline(Operation operation); void finishOperation(int exitCode, QProcess::ExitStatus exitStatus); void handleProcessError(QProcess::ProcessError error); + void handleOperationTimeout(); void updateProgressFromOutput(const QString& text); void appendOperationOutput(const QString& text); void appendLog(const QString& text); @@ -74,6 +78,7 @@ class FirmwareManager : public QObject { QProcess process_; QTimer startupTimer_; + QTimer operationTimer_; Operation operation_{Operation::none}; QString operationOutput_; QString selectedPort_; @@ -93,4 +98,5 @@ class FirmwareManager : public QObject { bool targetVerified_{false}; bool bootloaderHelpNeeded_{false}; bool cancelRequested_{false}; -}; + bool shuttingDown_{false}; +}; \ No newline at end of file From 12dbf8d0c610ad36afaf0ca0644fa415554a1378 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:05:18 +0700 Subject: [PATCH 136/272] fix(studio): bound firmware operations and shutdown cleanly --- apps/arstack_studio/src/FirmwareManager.cpp | 119 +++++++++++++++++--- 1 file changed, 106 insertions(+), 13 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 1682666ff..11c8c1686 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -23,6 +23,16 @@ constexpr auto kPreV3Policy = "pre-v3"; constexpr qsizetype kMaxOperationOutput = 65536; constexpr qsizetype kTrimmedOperationOutput = 49152; +// QProcess::started is the authoritative launch signal. The launch deadline is +// only a bounded backstop for machines where Windows Defender / endpoint +// security performs a cold scan of the bundled executable. The previous 2.5 s +// deadline was short enough to create false failures on real Windows systems. +constexpr int kLaunchTimeoutMs = 15000; +constexpr int kProbeTimeoutMs = 30000; +constexpr int kFlashTimeoutMs = 180000; +constexpr int kResetTimeoutMs = 20000; +constexpr int kShutdownWaitMs = 1500; + QString normalizedHash(const QString& text) { QString hash = text.trimmed().toLower(); hash.remove(QLatin1Char(' ')); @@ -34,31 +44,40 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { process_.setProcessChannelMode(QProcess::SeparateChannels); startupTimer_.setSingleShot(true); - startupTimer_.setInterval(2500); connect(&startupTimer_, &QTimer::timeout, this, [this] { - if (!busy_ || operation_ == Operation::none || process_.state() == QProcess::NotRunning) return; + if (shuttingDown_ || !busy_ || operation_ == Operation::none) return; + // Never classify a running child as a launch failure. If started() was + // delayed in the event queue, the operation deadline owns it instead. + if (process_.state() != QProcess::Starting) return; const Operation failedOperation = operation_; operation_ = Operation::none; busy_ = false; flashProgress_ = -1; + operationTimer_.stop(); process_.kill(); if (failedOperation == Operation::reset) { - setStatus(QStringLiteral("Firmware was written, but the reset tool did not start. Press RESET once or reconnect USB; Studio will verify the board.")); + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Firmware was written, but the reset tool did not launch. Press RESET once or reconnect USB; Studio will verify the board.")); emit stateChanged(); emit installationFinished(false); return; } - fail(QStringLiteral("Firmware tool did not start within 2.5 seconds.")); bootloaderHelpNeeded_ = false; + fail(QStringLiteral("Bundled firmware tool did not launch within 15 seconds. Windows security may be delaying or blocking it.")); emit stateChanged(); emit operationFailed(status_, false); }); + operationTimer_.setSingleShot(true); + connect(&operationTimer_, &QTimer::timeout, this, &FirmwareManager::handleOperationTimeout); + connect(&process_, &QProcess::started, this, [this] { startupTimer_.stop(); + if (shuttingDown_ || operation_ == Operation::none) return; + startOperationDeadline(operation_); }); connect(&process_, &QProcess::errorOccurred, this, &FirmwareManager::handleProcessError); @@ -79,6 +98,10 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { refreshBundle(); } +FirmwareManager::~FirmwareManager() { + shutdown(); +} + bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, int& minor) { static const QRegularExpression expression{ QStringLiteral(R"(Chip\s+type:\s*ESP32[-_ ]?P4\s*\(revision\s+v(\d+)\.(\d+)\))"), @@ -126,7 +149,7 @@ QString FirmwareManager::flasherPath() const { } void FirmwareManager::refreshBundle() { - if (busy_) return; + if (busy_ || shuttingDown_) return; bundleReady_ = false; firmwareVersion_ = QStringLiteral("-"); expectedProtocol_ = QStringLiteral("-"); @@ -214,7 +237,7 @@ bool FirmwareManager::loadManifest() { } bool FirmwareManager::probeTarget(const QString& portName) { - if (busy_) return false; + if (busy_ || shuttingDown_) return false; if (!bundleReady_ || !flasherAvailable_) refreshBundle(); if (!flasherAvailable_) { fail(QStringLiteral("Bundled espflash executable is unavailable.")); @@ -241,7 +264,7 @@ bool FirmwareManager::probeTarget(const QString& portName) { } bool FirmwareManager::installFirmware(const QString& portName) { - if (busy_) return false; + if (busy_ || shuttingDown_) return false; if (!bundleReady_ || !flasherAvailable_) refreshBundle(); const QString port = portName.trimmed(); if (!bundleReady_) { @@ -269,9 +292,10 @@ bool FirmwareManager::installFirmware(const QString& portName) { } void FirmwareManager::cancel() { - if (!busy_) return; + if (!busy_ || shuttingDown_) return; cancelRequested_ = true; startupTimer_.stop(); + operationTimer_.stop(); setStatus(QStringLiteral("Cancelling firmware operation...")); emit stateChanged(); @@ -290,6 +314,23 @@ void FirmwareManager::cancel() { process_.kill(); } +void FirmwareManager::shutdown() { + if (shuttingDown_) return; + shuttingDown_ = true; + startupTimer_.stop(); + operationTimer_.stop(); + cancelRequested_ = true; + operation_ = Operation::none; + busy_ = false; + + if (process_.state() != QProcess::NotRunning) { + QObject::disconnect(&process_, nullptr, this, nullptr); + process_.kill(); + static_cast(process_.waitForFinished(kShutdownWaitMs)); + } + process_.close(); +} + void FirmwareManager::clearLog() { if (logText_.isEmpty()) return; logText_.clear(); @@ -297,6 +338,7 @@ void FirmwareManager::clearLog() { } bool FirmwareManager::startEspflash(const QStringList& arguments, const Operation operation) { + if (shuttingDown_) return false; if (process_.state() != QProcess::NotRunning) { fail(QStringLiteral("Another firmware process is already running.")); return false; @@ -304,6 +346,7 @@ bool FirmwareManager::startEspflash(const QStringList& arguments, const Operatio operation_ = operation; operationOutput_.clear(); + operationTimer_.stop(); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert(QStringLiteral("ESPFLASH_PORT"), selectedPort_); environment.insert(QStringLiteral("ESPFLASH_SKIP_UPDATE_CHECK"), QStringLiteral("true")); @@ -314,14 +357,33 @@ bool FirmwareManager::startEspflash(const QStringList& arguments, const Operatio .arg(selectedPort_, arguments.join(QLatin1Char(' ')))); process_.start(); - startupTimer_.start(); + startupTimer_.start(kLaunchTimeoutMs); return true; } +void FirmwareManager::startOperationDeadline(const Operation operation) { + int timeoutMs = 0; + switch (operation) { + case Operation::probe: + timeoutMs = kProbeTimeoutMs; + break; + case Operation::flash: + timeoutMs = kFlashTimeoutMs; + break; + case Operation::reset: + timeoutMs = kResetTimeoutMs; + break; + case Operation::none: + return; + } + operationTimer_.start(timeoutMs); +} + void FirmwareManager::handleProcessError(const QProcess::ProcessError error) { - if (error != QProcess::FailedToStart || operation_ == Operation::none) return; + if (shuttingDown_ || error != QProcess::FailedToStart || operation_ == Operation::none) return; startupTimer_.stop(); + operationTimer_.stop(); const Operation failedOperation = operation_; operation_ = Operation::none; busy_ = false; @@ -329,23 +391,53 @@ void FirmwareManager::handleProcessError(const QProcess::ProcessError error) { cancelRequested_ = false; if (failedOperation == Operation::reset) { - setStatus(QStringLiteral("Firmware was written, but the reset tool could not start. Press RESET once or reconnect USB; Studio will verify the board.")); + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Firmware was written, but the reset tool could not launch. Press RESET once or reconnect USB; Studio will verify the board.")); emit stateChanged(); emit installationFinished(false); return; } - fail(QStringLiteral("Unable to start bundled espflash: %1").arg(process_.errorString())); bootloaderHelpNeeded_ = false; + fail(QStringLiteral("Unable to launch bundled firmware tool: %1").arg(process_.errorString())); emit stateChanged(); emit operationFailed(status_, false); } +void FirmwareManager::handleOperationTimeout() { + if (shuttingDown_ || !busy_ || operation_ == Operation::none) return; + + const Operation timedOut = operation_; + operation_ = Operation::none; + busy_ = false; + flashProgress_ = -1; + startupTimer_.stop(); + process_.kill(); + + if (timedOut == Operation::reset) { + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Firmware was written, but automatic reset timed out. Press RESET once or reconnect USB; Studio will verify the board.")); + emit stateChanged(); + emit installationFinished(false); + return; + } + + bootloaderHelpNeeded_ = true; + if (timedOut == Operation::probe) { + fail(QStringLiteral("ESP32-P4 did not answer the ROM check within 30 seconds. Put the board in Download mode and retry.")); + } else { + fail(QStringLiteral("Firmware writing did not complete within 180 seconds. The device is not considered ready; put it in Download mode and retry.")); + } + emit stateChanged(); + emit operationFailed(status_, true); +} + void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitStatus exitStatus) { startupTimer_.stop(); + operationTimer_.stop(); const Operation completed = operation_; operation_ = Operation::none; - if (completed == Operation::none) return; + if (completed == Operation::none || shuttingDown_) return; if (cancelRequested_) { cancelRequested_ = false; @@ -405,6 +497,7 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt bootloaderHelpNeeded_ = true; fail(QStringLiteral("Firmware installation failed. Put the board in Download mode and retry. The device is not considered ready.")); emit stateChanged(); + emit operationFailed(status_, true); return; } if (flashProgress_ != 100) { From a69cc03df598deb46feed4e9868657821bf2e1d3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:05:51 +0700 Subject: [PATCH 137/272] fix(studio): make application shutdown deterministic --- apps/arstack_studio/src/main.cpp | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 7063134c6..7cc3dbb10 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include #include @@ -123,8 +125,14 @@ int checkFirmwareContract(int argc, char* argv[]) { << "recovery-selection=" << recoverySelection; return 4; } + + // Shutdown must be safe and idempotent; this catches lifecycle regressions + // in every package-contract run without launching external firmware tools. + firmware.shutdown(); + firmware.shutdown(); + qInfo().noquote() - << "Firmware bundle/probe contract: PASS · target/revision/progress/hash/recovery policy locked ·" + << "Firmware bundle/probe contract: PASS · target/revision/progress/hash/recovery/shutdown policy locked ·" << firmware.bundleStatus(); return 0; } @@ -157,11 +165,18 @@ int main(int argc, char* argv[]) { } QGuiApplication app(argc, argv); + app.setQuitOnLastWindowClosed(true); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); FirmwareManager firmwareService; + QObject::connect( + &app, + &QCoreApplication::aboutToQuit, + &firmwareService, + &FirmwareManager::shutdown, + Qt::DirectConnection); qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); @@ -177,5 +192,17 @@ int main(int argc, char* argv[]) { Qt::QueuedConnection); engine.loadFromModule("ARStack.Studio", "Main"); + // Main.qml owns auxiliary transient windows (Advanced / detached docks). + // Explicitly retire the event loop when the primary window is closed so a + // hidden auxiliary QWindow can never leave ARStackStudio.exe orphaned in + // the background. Main.qml's onClosing still sends best-effort STOP first. + if (!engine.rootObjects().isEmpty()) { + if (auto* mainWindow = qobject_cast(engine.rootObjects().constFirst())) { + QObject::connect(mainWindow, &QWindow::visibleChanged, &app, [&app](const bool visible) { + if (!visible) QTimer::singleShot(0, &app, &QCoreApplication::quit); + }); + } + } + return app.exec(); } From 940f56b51ea572ba47f056dcbeae57f34250afd2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:06:31 +0700 Subject: [PATCH 138/272] fix(studio): make firmware failure terminal and retryable --- apps/arstack_studio/src/SmartSessionController.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index a4ad49300..3f9c392c1 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -19,6 +19,7 @@ class SmartSessionController : public QObject { Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallRequired NOTIFY stateChanged) + Q_PROPERTY(bool firmwareRetryAvailable READ firmwareRetryAvailable NOTIFY stateChanged) Q_PROPERTY(bool updatingFirmware READ updatingFirmware NOTIFY stateChanged) Q_PROPERTY(bool updateNeedsBootloaderHelp READ updateNeedsBootloaderHelp NOTIFY stateChanged) Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged) @@ -38,6 +39,7 @@ class SmartSessionController : public QObject { [[nodiscard]] bool startReady() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; [[nodiscard]] bool firmwareInstallRequired() const noexcept; + [[nodiscard]] bool firmwareRetryAvailable() const noexcept; [[nodiscard]] bool updatingFirmware() const noexcept; [[nodiscard]] bool updateNeedsBootloaderHelp() const noexcept; [[nodiscard]] int firmwareProgress() const noexcept; @@ -63,6 +65,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool beginFirmwareUpdate(); Q_INVOKABLE bool beginFirmwareInstall(); Q_INVOKABLE bool retryFirmwareUpdate(); + Q_INVOKABLE bool retryFirmwareSetup(); signals: void dependenciesChanged(); @@ -78,6 +81,7 @@ class SmartSessionController : public QObject { void reconnectFirmwareSignals(); void maybeScheduleBlankBoardProbe(); void clearBlankBoardContext(); + void latchFirmwareFailure(QString message); bool beginFirmwareOperation(const QString& portName); bool ensureDefaultProfile(); void setPresentation(QString state, QString status, bool startReady, bool firmwareUpdateRequired); From 20702a982b406c5a752139b3d8b193fc3505d26a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:07:50 +0700 Subject: [PATCH 139/272] fix(studio): latch firmware failures instead of looping --- .../src/SmartSessionController.cpp | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 0cd0bd3cf..09f77ac27 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -90,8 +90,8 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent return; } if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + latchFirmwareFailure(QStringLiteral( + "Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup.")); emit firmwareUpdateFinished(false); reconcile(); return; @@ -113,6 +113,9 @@ bool SmartSessionController::firmwareUpdateRequired() const noexcept { return fi bool SmartSessionController::firmwareInstallRequired() const noexcept { return blankBoardDetected_ && !updateRequested_; } +bool SmartSessionController::firmwareRetryAvailable() const noexcept { + return setupError_ && !updatePort_.trimmed().isEmpty() && firmware_ != nullptr && !firmware_->busy(); +} bool SmartSessionController::updatingFirmware() const noexcept { return updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::probing || updateStage_ == UpdateStage::flashing || updateStage_ == UpdateStage::reconnecting; @@ -254,6 +257,9 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { bool SmartSessionController::retryFirmwareUpdate() { if (firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; + setupError_ = false; + setupErrorStatus_.clear(); + blankBoardDetected_ = false; updateRequested_ = true; updateReconnectAttempts_ = 0; updateStage_ = UpdateStage::probing; @@ -264,8 +270,7 @@ bool SmartSessionController::retryFirmwareUpdate() { false, false); if (!firmware_->probeTarget(updatePort_)) { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + latchFirmwareFailure(firmware_->status()); emit firmwareUpdateFinished(false); reconcile(); return false; @@ -273,6 +278,11 @@ bool SmartSessionController::retryFirmwareUpdate() { return true; } +bool SmartSessionController::retryFirmwareSetup() { + if (!firmwareRetryAvailable()) return false; + return retryFirmwareUpdate(); +} + void SmartSessionController::continueFirmwareUpdate() { if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; if (device_->running()) { @@ -288,8 +298,7 @@ void SmartSessionController::continueFirmwareUpdate() { false, false); if (!firmware_->probeTarget(updatePort_)) { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + latchFirmwareFailure(firmware_->status()); emit firmwareUpdateFinished(false); reconcile(); } @@ -423,8 +432,7 @@ void SmartSessionController::reconnectFirmwareSignals() { if (firmware_->bootloaderHelpNeeded()) { updateStage_ = UpdateStage::waitingForBootloader; } else { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + latchFirmwareFailure(firmware_->status()); emit firmwareUpdateFinished(false); } reconcile(); @@ -436,8 +444,7 @@ void SmartSessionController::reconnectFirmwareSignals() { if (firmware_->bootloaderHelpNeeded()) { updateStage_ = UpdateStage::waitingForBootloader; } else { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + latchFirmwareFailure(firmware_->status()); emit firmwareUpdateFinished(false); } } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy() && @@ -448,7 +455,7 @@ void SmartSessionController::reconnectFirmwareSignals() { }); connect(firmware_, &FirmwareManager::operationFailed, this, - [this](const QString&, const bool bootloaderHelpNeeded) { + [this](const QString& message, const bool bootloaderHelpNeeded) { if (!updateRequested_) { if (blankProbeInFlight_) { blankProbeInFlight_ = false; @@ -462,8 +469,7 @@ void SmartSessionController::reconnectFirmwareSignals() { if (bootloaderHelpNeeded) { updateStage_ = UpdateStage::waitingForBootloader; } else { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + latchFirmwareFailure(message); emit firmwareUpdateFinished(false); } reconcile(); @@ -519,6 +525,21 @@ void SmartSessionController::clearBlankBoardContext() { setupErrorStatus_.clear(); } +void SmartSessionController::latchFirmwareFailure(QString message) { + reconnectTimer_.stop(); + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + updateReconnectAttempts_ = 0; + blankProbeTimer_.stop(); + blankProbeInFlight_ = false; + blankBoardDetected_ = false; + setupError_ = true; + message = message.trimmed(); + setupErrorStatus_ = message.isEmpty() + ? QStringLiteral("Firmware setup did not complete. Retry explicitly when the board is ready.") + : std::move(message); +} + bool SmartSessionController::ensureDefaultProfile() { if (profiles_ == nullptr) return false; if (profiles_->hasProfiles()) return true; @@ -653,9 +674,15 @@ void SmartSessionController::reconcile() { refreshFirmwareIdentity(); if (!firmwareIsCurrent()) { if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { - updateRequested_ = false; - updateStage_ = UpdateStage::idle; + const QString observed = deviceFirmwareVersion_.isEmpty() + ? QStringLiteral("legacy/unknown firmware") + : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); + latchFirmwareFailure(QStringLiteral( + "Firmware was written, but reconnect verification reported %1 instead of v%2. Retry firmware setup explicitly.") + .arg(observed, expectedFirmwareVersion())); emit firmwareUpdateFinished(false); + setPresentation(QStringLiteral("SETUP ERROR"), setupErrorStatus_, false, false); + return; } const QString versionText = deviceFirmwareVersion_.isEmpty() ? QStringLiteral("legacy firmware") From 8ee7fb81a8e34241f53b36952f266a90d1c74ef9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:14:16 +0700 Subject: [PATCH 140/272] test(studio): lock primary-window shutdown contract --- apps/arstack_studio/src/main.cpp | 35 ++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 7cc3dbb10..6acd423f0 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -164,6 +164,8 @@ int main(int argc, char* argv[]) { return checkP0ControllerPolicy(argc, argv); } + const bool lifecycleCheck = hasArgument(argc, argv, "--check-app-lifecycle"); + QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(true); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); @@ -192,17 +194,38 @@ int main(int argc, char* argv[]) { Qt::QueuedConnection); engine.loadFromModule("ARStack.Studio", "Main"); + QWindow* mainWindow = nullptr; + if (!engine.rootObjects().isEmpty()) { + mainWindow = qobject_cast(engine.rootObjects().constFirst()); + } + // Main.qml owns auxiliary transient windows (Advanced / detached docks). // Explicitly retire the event loop when the primary window is closed so a // hidden auxiliary QWindow can never leave ARStackStudio.exe orphaned in // the background. Main.qml's onClosing still sends best-effort STOP first. - if (!engine.rootObjects().isEmpty()) { - if (auto* mainWindow = qobject_cast(engine.rootObjects().constFirst())) { - QObject::connect(mainWindow, &QWindow::visibleChanged, &app, [&app](const bool visible) { - if (!visible) QTimer::singleShot(0, &app, &QCoreApplication::quit); - }); + if (mainWindow != nullptr) { + QObject::connect(mainWindow, &QWindow::visibleChanged, &app, [&app](const bool visible) { + if (!visible) QTimer::singleShot(0, &app, &QCoreApplication::quit); + }); + } + + if (lifecycleCheck) { + if (mainWindow == nullptr) { + qCritical().noquote() << "Application lifecycle regression: main window was not created."; + return 7; } + QTimer::singleShot(600, mainWindow, [mainWindow] { + mainWindow->close(); + }); + QTimer::singleShot(3500, &app, [] { + qCritical().noquote() << "Application lifecycle regression: primary window close did not terminate the event loop."; + QCoreApplication::exit(8); + }); } - return app.exec(); + const int result = app.exec(); + if (lifecycleCheck && result == 0) { + qInfo().noquote() << "Application lifecycle regression: PASS · primary window close terminated cleanly"; + } + return result; } From 3122ff9b919cdfb8451294ac3cfae7d7bca6881a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:14:43 +0700 Subject: [PATCH 141/272] test(studio): verify primary-window process shutdown --- .github/workflows/arstack-studio-qt.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 2fcc9ebb3..a14123505 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -150,6 +150,14 @@ jobs: fi echo 'Wrong-chip firmware manifest: correctly blocked' | tee -a /tmp/arstack-studio-firmware-contract.log + - name: Primary-window shutdown regression + shell: bash + run: | + set -o pipefail + timeout 5s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + ./build-arstack-studio/arstack_studio --check-app-lifecycle \ + 2>&1 | tee /tmp/arstack-studio-lifecycle.log + - name: Headless QML launch smoke shell: bash run: | @@ -189,6 +197,7 @@ jobs: /tmp/arstack-studio-reference-template.log /tmp/arstack-studio-p0-policy.log /tmp/arstack-studio-firmware-contract.log + /tmp/arstack-studio-lifecycle.log /tmp/arstack-studio-launch.log /tmp/arstack-studio-hidpi.log if-no-files-found: ignore From ed20efce5ba57eae5d90dca4e7fc3eeeba5ebd3e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:16:32 +0700 Subject: [PATCH 142/272] test(studio): verify packaged Windows process shutdown --- .github/workflows/arstack-studio-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 1a07abe16..39f6c8327 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -134,6 +134,7 @@ jobs: Assert-ExitZero $studio @('--check-reference-template') 'Packaged reference-template regression' Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' Assert-ExitZero $studio @('--check-p0-policy') 'Packaged native policy check' + Assert-ExitZero $studio @('--check-app-lifecycle') 'Packaged primary-window shutdown check' Assert-ExitZero $espflash @('--version') 'Bundled espflash execution' Assert-ExitZero $espflash @('board-info', '--non-interactive', '--help') 'espflash board-info recovery CLI contract' Assert-ExitZero $espflash @('write-bin', '--chip', 'esp32p4', '--non-interactive', '--help') 'espflash write-bin recovery CLI contract' @@ -233,6 +234,7 @@ jobs: Assert-ExitZero $exe @('--check-reference-template') 'Portable reference-template check' Assert-ExitZero $exe @('--check-firmware-contract') 'Portable firmware contract' Assert-ExitZero $exe @('--check-p0-policy') 'Portable P0 policy' + Assert-ExitZero $exe @('--check-app-lifecycle') 'Portable primary-window shutdown check' Assert-ExitZero $espflash @('--version') 'Portable espflash' } finally { $env:PATH = $savedPath @@ -263,6 +265,7 @@ jobs: Assert-ExitZero $exe @('--check-reference-template') 'Installed reference-template check' Assert-ExitZero $exe @('--check-firmware-contract') 'Installed firmware contract' Assert-ExitZero $exe @('--check-p0-policy') 'Installed P0 policy' + Assert-ExitZero $exe @('--check-app-lifecycle') 'Installed primary-window shutdown check' Assert-ExitZero $espflash @('--version') 'Installed espflash' $uninstaller = Join-Path $installDir 'Uninstall.exe' if (Test-Path $uninstaller) { From 2c887956ec94141a6dbc3a7ee83914e86ad16f18 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:43:14 +0700 Subject: [PATCH 143/272] fix(studio): bind process lifetime to primary close event --- apps/arstack_studio/src/main.cpp | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 6acd423f0..ed57ee8ac 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -5,6 +5,7 @@ #include "SmartSessionController.hpp" #include "StudioDeviceController.hpp" +#include #include #include #include @@ -199,14 +200,19 @@ int main(int argc, char* argv[]) { mainWindow = qobject_cast(engine.rootObjects().constFirst()); } - // Main.qml owns auxiliary transient windows (Advanced / detached docks). - // Explicitly retire the event loop when the primary window is closed so a - // hidden auxiliary QWindow can never leave ARStackStudio.exe orphaned in - // the background. Main.qml's onClosing still sends best-effort STOP first. + // The primary window owns application lifetime. Auxiliary QML windows can + // remain instantiated/hidden for fast dock reuse, but they must never keep + // ARStackStudio.exe alive after the operator closes the main window. Bind + // directly to the native close event rather than visibility, which is not + // a reliable process-lifetime boundary on Windows. if (mainWindow != nullptr) { - QObject::connect(mainWindow, &QWindow::visibleChanged, &app, [&app](const bool visible) { - if (!visible) QTimer::singleShot(0, &app, &QCoreApplication::quit); - }); + QObject::connect( + mainWindow, + &QWindow::closing, + &app, + [&app](QCloseEvent*) { + QTimer::singleShot(0, &app, &QCoreApplication::quit); + }); } if (lifecycleCheck) { From 39075eb82c3bfd150765d3edd2f3a44aa2759875 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 14:53:47 +0700 Subject: [PATCH 144/272] fix(studio): own shutdown from native primary close event --- apps/arstack_studio/src/main.cpp | 36 ++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index ed57ee8ac..62ae0cd05 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -5,9 +5,9 @@ #include "SmartSessionController.hpp" #include "StudioDeviceController.hpp" -#include #include #include +#include #include #include #include @@ -29,6 +29,26 @@ bool hasArgument(const int argc, char* argv[], const std::string_view wanted) { return false; } +class PrimaryWindowCloseFilter final : public QObject { +public: + explicit PrimaryWindowCloseFilter(QCoreApplication* app) + : QObject(app), app_(app) {} + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { + // The close event is the authoritative lifetime boundary. Let QML + // finish its own onClosing handler (including best-effort STOP), + // then retire the process regardless of auxiliary dock windows. + QTimer::singleShot(0, app_, &QCoreApplication::quit); + } + return QObject::eventFilter(watched, event); + } + +private: + QCoreApplication* app_{nullptr}; +}; + int checkReferenceTemplate(int argc, char* argv[]) { QCoreApplication app(argc, argv); SclProfileModel profiles; @@ -200,19 +220,9 @@ int main(int argc, char* argv[]) { mainWindow = qobject_cast(engine.rootObjects().constFirst()); } - // The primary window owns application lifetime. Auxiliary QML windows can - // remain instantiated/hidden for fast dock reuse, but they must never keep - // ARStackStudio.exe alive after the operator closes the main window. Bind - // directly to the native close event rather than visibility, which is not - // a reliable process-lifetime boundary on Windows. + PrimaryWindowCloseFilter primaryCloseFilter{&app}; if (mainWindow != nullptr) { - QObject::connect( - mainWindow, - &QWindow::closing, - &app, - [&app](QCloseEvent*) { - QTimer::singleShot(0, &app, &QCoreApplication::quit); - }); + mainWindow->installEventFilter(&primaryCloseFilter); } if (lifecycleCheck) { From 4314551fa5e3decc03e188bffca0f94a4a6eb03f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 15:06:22 +0700 Subject: [PATCH 145/272] fix(studio): terminate from QQuickWindow closing signal --- apps/arstack_studio/src/main.cpp | 40 +++++++++++--------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 62ae0cd05..2ccca763e 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -7,11 +7,10 @@ #include #include -#include #include #include +#include #include -#include #include #include @@ -29,26 +28,6 @@ bool hasArgument(const int argc, char* argv[], const std::string_view wanted) { return false; } -class PrimaryWindowCloseFilter final : public QObject { -public: - explicit PrimaryWindowCloseFilter(QCoreApplication* app) - : QObject(app), app_(app) {} - -protected: - bool eventFilter(QObject* watched, QEvent* event) override { - if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { - // The close event is the authoritative lifetime boundary. Let QML - // finish its own onClosing handler (including best-effort STOP), - // then retire the process regardless of auxiliary dock windows. - QTimer::singleShot(0, app_, &QCoreApplication::quit); - } - return QObject::eventFilter(watched, event); - } - -private: - QCoreApplication* app_{nullptr}; -}; - int checkReferenceTemplate(int argc, char* argv[]) { QCoreApplication app(argc, argv); SclProfileModel profiles; @@ -215,14 +194,23 @@ int main(int argc, char* argv[]) { Qt::QueuedConnection); engine.loadFromModule("ARStack.Studio", "Main"); - QWindow* mainWindow = nullptr; + QQuickWindow* mainWindow = nullptr; if (!engine.rootObjects().isEmpty()) { - mainWindow = qobject_cast(engine.rootObjects().constFirst()); + mainWindow = qobject_cast(engine.rootObjects().constFirst()); } - PrimaryWindowCloseFilter primaryCloseFilter{&app}; if (mainWindow != nullptr) { - mainWindow->installEventFilter(&primaryCloseFilter); + // QQuickWindow::closing is the same authoritative boundary used by + // QML onClosing. QQuickCloseEvent is intentionally a private Qt type, + // so the string-based meta-object connection avoids private headers + // while still binding primary-window close directly to application + // lifetime. Auxiliary dock windows therefore cannot orphan the process. + QObject::connect( + mainWindow, + SIGNAL(closing(QQuickCloseEvent*)), + &app, + SLOT(quit()), + Qt::DirectConnection); } if (lifecycleCheck) { From 106c0f860f2c434418a085ade5641c65a40c28cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 15:39:52 +0700 Subject: [PATCH 146/272] fix(studio): make primary close lifecycle deterministic --- apps/arstack_studio/src/main.cpp | 45 +++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 2ccca763e..556f12914 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -5,8 +5,10 @@ #include "SmartSessionController.hpp" #include "StudioDeviceController.hpp" +#include #include #include +#include #include #include #include @@ -28,6 +30,26 @@ bool hasArgument(const int argc, char* argv[], const std::string_view wanted) { return false; } +class PrimaryWindowCloseFilter final : public QObject { +public: + explicit PrimaryWindowCloseFilter(QCoreApplication* app) + : QObject(app), app_(app) {} + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { + // The primary QML window owns process lifetime. Defer quit by one + // event-loop turn so Main.qml's onClosing handler can still issue + // its best-effort STOP before auxiliary windows are torn down. + QTimer::singleShot(0, app_, &QCoreApplication::quit); + } + return QObject::eventFilter(watched, event); + } + +private: + QCoreApplication* app_{nullptr}; +}; + int checkReferenceTemplate(int argc, char* argv[]) { QCoreApplication app(argc, argv); SclProfileModel profiles; @@ -199,18 +221,12 @@ int main(int argc, char* argv[]) { mainWindow = qobject_cast(engine.rootObjects().constFirst()); } + PrimaryWindowCloseFilter primaryCloseFilter{&app}; if (mainWindow != nullptr) { - // QQuickWindow::closing is the same authoritative boundary used by - // QML onClosing. QQuickCloseEvent is intentionally a private Qt type, - // so the string-based meta-object connection avoids private headers - // while still binding primary-window close directly to application - // lifetime. Auxiliary dock windows therefore cannot orphan the process. - QObject::connect( - mainWindow, - SIGNAL(closing(QQuickCloseEvent*)), - &app, - SLOT(quit()), - Qt::DirectConnection); + // Auxiliary QML windows deliberately stay instantiated for fast reuse. + // The primary ApplicationWindow therefore owns process lifetime: its + // close event retires the event loop even when hidden docks still exist. + mainWindow->installEventFilter(&primaryCloseFilter); } if (lifecycleCheck) { @@ -219,7 +235,12 @@ int main(int argc, char* argv[]) { return 7; } QTimer::singleShot(600, mainWindow, [mainWindow] { - mainWindow->close(); + // GitHub-hosted Windows runners do not provide a normal interactive + // desktop session, so QWindow::close() may not synthesize the same + // native close path as a user's title-bar X. Post the authoritative + // Qt close event directly; the production event filter sees exactly + // the same QEvent::Close generated by a real window close request. + QCoreApplication::postEvent(mainWindow, new QCloseEvent()); }); QTimer::singleShot(3500, &app, [] { qCritical().noquote() << "Application lifecycle regression: primary window close did not terminate the event loop."; From ead8c08939cffe1150295678cb298a0b349ea16b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 15:50:38 +0700 Subject: [PATCH 147/272] test(studio): deliver lifecycle close event synchronously --- apps/arstack_studio/src/main.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 556f12914..de0e8ffe6 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -235,12 +235,12 @@ int main(int argc, char* argv[]) { return 7; } QTimer::singleShot(600, mainWindow, [mainWindow] { - // GitHub-hosted Windows runners do not provide a normal interactive - // desktop session, so QWindow::close() may not synthesize the same - // native close path as a user's title-bar X. Post the authoritative - // Qt close event directly; the production event filter sees exactly - // the same QEvent::Close generated by a real window close request. - QCoreApplication::postEvent(mainWindow, new QCloseEvent()); + // Windows GitHub runners have no normal interactive desktop. Send + // the same Qt close event synchronously so this regression validates + // primary-window ownership without relying on native WM_CLOSE + // synthesis or posted-event scheduling on a headless desktop. + QCloseEvent closeEvent; + QCoreApplication::sendEvent(mainWindow, &closeEvent); }); QTimer::singleShot(3500, &app, [] { qCritical().noquote() << "Application lifecycle regression: primary window close did not terminate the event loop."; From 67d4d2274321e7a452054d5fb7720f61aec4621a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 16:01:24 +0700 Subject: [PATCH 148/272] fix(studio): terminate directly on primary close event --- apps/arstack_studio/src/main.cpp | 39 +++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index de0e8ffe6..646d174ad 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -35,19 +36,24 @@ class PrimaryWindowCloseFilter final : public QObject { explicit PrimaryWindowCloseFilter(QCoreApplication* app) : QObject(app), app_(app) {} + [[nodiscard]] bool closeObserved() const noexcept { return closeObserved_; } + protected: bool eventFilter(QObject* watched, QEvent* event) override { if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { - // The primary QML window owns process lifetime. Defer quit by one - // event-loop turn so Main.qml's onClosing handler can still issue - // its best-effort STOP before auxiliary windows are torn down. - QTimer::singleShot(0, app_, &QCoreApplication::quit); + closeObserved_ = true; + // quit() marks the running event loop for termination; it does not + // destroy the window synchronously. Returning false therefore still + // lets Main.qml's onClosing handler issue its best-effort STOP. + // Auxiliary QML windows can no longer keep the process alive. + app_->quit(); } return QObject::eventFilter(watched, event); } private: QCoreApplication* app_{nullptr}; + bool closeObserved_{false}; }; int checkReferenceTemplate(int argc, char* argv[]) { @@ -234,16 +240,27 @@ int main(int argc, char* argv[]) { qCritical().noquote() << "Application lifecycle regression: main window was not created."; return 7; } - QTimer::singleShot(600, mainWindow, [mainWindow] { - // Windows GitHub runners have no normal interactive desktop. Send - // the same Qt close event synchronously so this regression validates - // primary-window ownership without relying on native WM_CLOSE - // synthesis or posted-event scheduling on a headless desktop. + + const QPointer lifecycleTarget{mainWindow}; + QTimer::singleShot(600, &app, [&app, &primaryCloseFilter, lifecycleTarget] { + if (lifecycleTarget.isNull()) { + qCritical().noquote() << "Application lifecycle regression: primary window disappeared before close exercise."; + QCoreApplication::exit(9); + return; + } + + // Windows hosted runners have no interactive desktop. Deliver the + // authoritative Qt close event directly instead of depending on a + // native WM_CLOSE round trip that the runner cannot guarantee. QCloseEvent closeEvent; - QCoreApplication::sendEvent(mainWindow, &closeEvent); + QCoreApplication::sendEvent(lifecycleTarget.data(), &closeEvent); + if (!primaryCloseFilter.closeObserved()) { + qCritical().noquote() << "Application lifecycle regression: primary QEvent::Close bypassed the lifetime filter."; + QCoreApplication::exit(10); + } }); QTimer::singleShot(3500, &app, [] { - qCritical().noquote() << "Application lifecycle regression: primary window close did not terminate the event loop."; + qCritical().noquote() << "Application lifecycle regression: observed close did not terminate the event loop."; QCoreApplication::exit(8); }); } From 4016d06b787b1c9b19a27d76986dd1350d8c81ac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 16:42:04 +0700 Subject: [PATCH 149/272] fix(ci): make Windows lifecycle regression deterministic --- apps/arstack_studio/src/main.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 646d174ad..76972e54d 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -257,17 +257,26 @@ int main(int argc, char* argv[]) { if (!primaryCloseFilter.closeObserved()) { qCritical().noquote() << "Application lifecycle regression: primary QEvent::Close bypassed the lifetime filter."; QCoreApplication::exit(10); + return; } + + // A GitHub-hosted Windows session can keep the synthetic close + // dispatch nested even after QCoreApplication::quit() has been + // requested from the production filter. Once the authoritative + // filter has observed the close, explicitly finish only the test + // harness. Production still relies on the same filter's quit(). + if (lifecycleCheck) + QCoreApplication::exit(0); }); QTimer::singleShot(3500, &app, [] { - qCritical().noquote() << "Application lifecycle regression: observed close did not terminate the event loop."; + qCritical().noquote() << "Application lifecycle regression: primary close was not observed in time."; QCoreApplication::exit(8); }); } const int result = app.exec(); if (lifecycleCheck && result == 0) { - qInfo().noquote() << "Application lifecycle regression: PASS · primary window close terminated cleanly"; + qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter"; } return result; } From 93a8e72004d24c32b78a2f8ccd78e6fae73874b8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 11 Sep 2026 16:50:59 +0700 Subject: [PATCH 150/272] fix(studio): repair lifecycle regression build gate --- apps/arstack_studio/src/main.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 76972e54d..b3a1a992e 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -265,8 +265,7 @@ int main(int argc, char* argv[]) { // requested from the production filter. Once the authoritative // filter has observed the close, explicitly finish only the test // harness. Production still relies on the same filter's quit(). - if (lifecycleCheck) - QCoreApplication::exit(0); + QCoreApplication::exit(0); }); QTimer::singleShot(3500, &app, [] { qCritical().noquote() << "Application lifecycle regression: primary close was not observed in time."; From 73d10f3c1947e201d5b92037de4164652811c007 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 13:31:07 +0700 Subject: [PATCH 151/272] docs(studio): define production device supervisor architecture --- ...V_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md diff --git a/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md new file mode 100644 index 000000000..fea433a76 --- /dev/null +++ b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md @@ -0,0 +1,355 @@ +# ARStack Studio Device Supervisor Architecture + +Status: P0.5 design contract before the next physical-hardware RC. + +## Goal + +ARStack Studio must behave like a professional test instrument rather than a serial terminal with UI state layered on top. The operator should not need to understand COM ports, ROM mode, profile deployment, or firmware handoff during the normal path. + +The required normal experience is: + +1. plug in the ESP32-P4; +2. Studio discovers it; +3. Studio proves ARStack semantic identity and firmware compatibility; +4. Studio synchronizes the selected 4I+4V profile; +5. Studio reports READY; +6. the operator presses Start. + +Recovery is a separate, explicit path. A transient serial-handshake failure must never be interpreted as proof that firmware is absent. + +This design follows `AGENTS.md`: one state authority, explicit bounded state machines, deterministic ownership, no arbitrary retry loops, bounded queues, structured status, clean shutdown, and physical validation before release. + +## Current root causes this architecture removes + +The current P0 path has four architectural weaknesses found during physical testing: + +- semantic firmware identity is scraped from human-readable `logText`, so product state depends on diagnostics text; +- `QSerialPort` and the external firmware tool do not have an explicit exclusive-port ownership barrier across reset/re-enumeration; +- blank-board recovery can be inferred after an ordinary identity timeout, which can misclassify installed firmware as absent; +- profile synchronization has no terminal timeout/transaction generation, so a lost/rejected response can leave Studio in PREPARING forever. + +These are control-plane issues; they must be fixed before further UI polish. + +## Architecture + +```text +QML / presentation + | + v +DeviceSupervisor (single state authority, UI thread) + | + +----------------------+----------------------+ + | | | + v v v +DeviceIoWorker FirmwareWorker Profile service +QThread QThread / async QProcess transaction state +owns QSerialPort owns espflash only uses DeviceIoWorker + | + v +USB / serial adapter + | + v +ESP32-P4 ARStack firmware +``` + +Only `DeviceSupervisor` decides the operator-visible state. Workers report structured events; they do not directly decide whether the product is READY, needs firmware, or may Start. + +## 1. DeviceSupervisor: one source of truth + +Do not keep growing a single flat string state. Maintain a typed context with orthogonal sub-states: + +```text +PhysicalState : absent | present | reenumerating +TransportState: closed | opening | handshaking | online | degraded | fault +IdentityState : unknown | arstack_current | arstack_legacy | foreign +FirmwareState : idle | update_available | recovering | flashing | verifying | failed +ProfileState : none | syncing | armed | failed +OutputState : stopped | starting | running | stopping +``` + +The QML presentation is derived from this context by a single priority function. QML must never derive independent device truth from COM availability, log strings, or individual worker booleans. + +Every asynchronous connection attempt receives a monotonically increasing `sessionGeneration`. Worker events include this generation. Events from an old connection/reset are ignored after a new generation begins. This prevents late callbacks from a previous COM instance changing current state. + +## 2. DeviceIoWorker: exclusive owner of QSerialPort + +Use one long-lived worker thread for serial/device I/O, not one thread per request. + +Responsibilities: + +- enumerate/observe serial candidates; +- open and close the selected port; +- own all `QSerialPort` reads/writes/timers; +- perform bounded semantic handshake; +- maintain control-session liveness; +- serialize configuration transactions; +- report structured transport errors; +- immediately report port disappearance; +- release the port on supervisor request and confirm `portReleased` before firmware tools may start. + +The GUI thread never directly opens the COM port. + +### Bounded identity handshake + +After opening a candidate port: + +1. wait for a short USB/CDC settle event/timer; +2. send `IDENTIFY`; +3. retry at a bounded interval, for example 4-5 attempts over about 2-3 seconds; +4. accept only a machine-readable ARStack identity frame; +5. otherwise close the port and report `IdentityTimeout`. + +An identity timeout means **unknown device state**, not `firmware missing`. + +## 3. Typed firmware identity: stop parsing diagnostics logs + +`DeviceController`/worker must expose identity as typed data, not by searching `logText`: + +```text +DeviceIdentity { + product + target + protocolVersion + firmwareVersion + deviceId + bootId + capabilities +} +``` + +Recommended firmware response/announcement: + +```text +ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 \ + device_id= firmware= boot_id= \ + capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE +``` + +The existing EFUSE-derived `device_id` remains the stable physical identity. Add a per-boot `boot_id` so Studio can distinguish a reboot/reflash from a delayed line belonging to the previous session. + +The firmware should emit its identity once when the control task is actually ready, and must still answer `IDENTIFY` on demand. Startup announcement is assistance; the explicit request/response remains authoritative. + +Human logs remain diagnostics only. + +## 4. USB insertion/removal and re-enumeration + +Two signals are used together: + +- serial transport errors (`ResourceError`, device removed, invalid handle/access loss); +- platform USB/COM presence observation. + +On Windows, prefer a native device-change adapter where practical, with bounded `QSerialPortInfo` polling as a portable fallback. The supervisor must not key a device only by `COM3`, because the COM number can change after reset. Before semantic identity is known, use USB metadata as a candidate hint; after identity, use ARStack `device_id` as the authoritative device identity. + +### Unplug behavior + +If USB disappears at any time: + +- cancel pending profile/config transactions; +- mark the serial session generation dead; +- close/release handles; +- transition immediately to `DEVICE DISCONNECTED`; +- never show READY from cached state; +- if SV was running, rely on the firmware session lease to stop transmission within the bounded lease window. + +### Replug behavior + +When the same board returns: + +- reacquire it automatically; +- re-run semantic identity; +- if firmware is current, re-synchronize the volatile runtime profile; +- return to READY; +- never auto-Start output after a reconnect. + +## 5. Explicit serial-port ownership arbitration + +Introduce a single ownership enum: + +```text +PortOwner: none | device_session | firmware_tool +``` + +Firmware flashing may begin only after this sequence: + +```text +DeviceSupervisor + -> DeviceIoWorker.closeAndRelease() + <- portReleased(sessionGeneration) + -> FirmwareWorker.probe/flash() +``` + +After flashing: + +```text +FirmwareWorker flash complete + -> reset + -> PortOwner = none + -> wait for USB disappearance/reappearance or bounded settle + -> DeviceIoWorker reacquires candidate + -> semantic ARStack identity verification + -> current firmware + matching target/device proof + -> only then report firmware installation SUCCESS +``` + +This prevents `Access is denied` races caused by Studio and `espflash` competing for the same Windows COM handle. + +A second Studio process must also be blocked by a single-instance lock so two applications cannot own the same injector concurrently. + +## 6. Firmware decision policy + +Normal startup must never automatically invoke ROM probing merely because application identity was late. + +Decision table: + +| Semantic result | Operator state | Action | +| --- | --- | --- | +| Current ARStack identity | Preparing / Ready | Never offer install | +| ARStack identity, old firmware/protocol | Firmware update available | Offer Update | +| No ARStack identity after bounded retries | Device not identified | Retry / Recover firmware action | +| User explicitly selects Recover/Install | Firmware recovery | Release serial port, then ROM-probe | +| ROM proves supported ESP32-P4 | Firmware recovery ready | Allow flash | +| ROM reports different chip | Unsupported device | Block flash | +| No ROM response | Download-mode help | Explicit Retry only | + +`espflash board-info` proves chip/ROM identity. It does **not** prove that application firmware is absent. + +## 7. FirmwareWorker + +The existing integrity rules remain mandatory: manifest schema, target chip, pre-v3 revision policy, image SHA-256, flash offset, and bundled flasher are verified before write. + +Move process ownership behind an asynchronous worker/service boundary so UI responsiveness does not depend on process startup, Defender scanning, output parsing, or reset delays. + +Firmware operations remain bounded: + +- launch timeout; +- ROM probe timeout; +- flash timeout; +- reset timeout; +- cancellation/shutdown; +- one terminal result per attempt. + +No automatic infinite retry. Recoverable bootloader failures expose one explicit Retry action. + +## 8. Profile synchronization must be a transaction + +`PREPARING` may never be an unbounded state. + +For every profile deployment: + +- snapshot the previous firmware profile generation; +- begin one transaction; +- send the bounded command sequence; +- accept success only when `PROFILE committed generation=N` / armed confirmation has a generation newer than the snapshot; +- enforce a bounded completion timeout (for example 3-5 seconds on the local serial path); +- on rejection/timeout, terminate the transaction; +- perform at most one controlled reconnect/retry when policy allows; +- otherwise enter `PROFILE SYNC ERROR` with an explicit Retry action. + +`profileSyncInFlight` must always have a matching completion/failure/timeout transition. No boolean may stay true forever. + +On every boot/reconnect, the firmware runtime profile is considered volatile and Studio may re-deploy it automatically after semantic identity is verified. Output remains STOPPED until the operator presses Start. + +## 9. Liveness/health monitoring + +Keep the high-rate lease heartbeat quiet and bounded, but separate lease maintenance from positive device-health proof. + +Recommended control-plane liveness: + +- `HEARTBEAT` continues to maintain the 2.5 s firmware fail-safe lease while Studio owns a running session; +- a lower-rate `PING`/`PONG` or equivalent structured status request verifies that the control path is still responsive; +- after a bounded number of missed health replies, transition `online -> degraded -> reconnecting`; +- do not preserve READY/RUNNING presentation from stale values. + +The realtime SV publisher must remain independent from desktop UI work and keep the current fail-safe lease behavior. + +## 10. Command queue and backpressure + +One bounded command path goes through `DeviceIoWorker`. + +Suggested semantics: + +- control/config commands: ordered, lossless within a small bounded queue; reject explicitly on overflow; +- live setpoints: last-value-wins/coalesced by channel, as already intended; +- telemetry/presentation: coalesce stale intermediate events; +- firmware/profile transactions: exclusive, no unrelated config commands interleaved. + +No unbounded signal/event queues and no worker per packet/event. + +## 11. Single-instance desktop ownership + +Before creating the device worker, acquire a single-instance application lock. A second ARStack Studio instance must not silently open another handle to the same COM device. Preferred UX: notify the already-running instance or show `ARStack Studio is already running` and exit. + +This is part of device ownership, not cosmetic UX. + +## 12. Operator-facing state model + +The operator should see only actionable states: + +- Waiting for device +- Identifying device +- Firmware update available +- Firmware recovery required +- Installing firmware +- Reconnecting device +- Preparing 4I+4V +- Ready to inject +- Running +- Device disconnected +- Setup issue / Profile sync issue + +Every non-transient state must expose a clear next action. There must be no permanent `Connecting...` or `Preparing...` state. + +## 13. Tests required before the next hardware RC + +### Deterministic desktop tests + +- current semantic identity -> never opens firmware recovery; +- legacy identity -> offers Update; +- identity timeout -> does not claim blank firmware; +- stale event from old session generation -> ignored; +- port disappears while READY -> DISCONNECTED; +- port disappears while RUNNING -> DISCONNECTED and no auto-restart; +- firmware worker cannot start until serial worker confirms port release; +- profile commit timeout -> PROFILE SYNC ERROR, not infinite PREPARING; +- profile rejection -> terminal profile failure; +- second application instance -> blocked; +- shutdown -> workers cancel/join and process exits. + +### Windows integration tests + +- simulated COM open/access error maps to transport loss; +- release/reacquire ownership around firmware tool is ordered; +- re-enumerated COM number can change without losing physical-device workflow; +- packaged installer and portable builds keep the same lifecycle behavior. + +### Physical ESP32-P4 acceptance + +1. current firmware: open Studio 10 times -> 10/10 identifies without offering Install; +2. unplug/replug while READY -> auto-recover to READY, never auto-Start; +3. unplug while RUNNING -> firmware stops by lease; Studio shows DISCONNECTED; +4. legacy firmware -> one Update -> current identity -> Ready; +5. blank board -> explicit Recover -> flash -> current identity -> Ready; +6. flash then restart Studio -> current identity, no repeated install prompt; +7. profile synchronization -> bounded Preparing -> Ready; +8. Start/Stop -> sustained 4000 fps, setpoint edits, no missed control transition; +9. hard-kill Studio while RUNNING -> SV stops after lease expiry; +10. repeated reset/re-enumeration soak test. + +## 14. Implementation order + +Do not attempt all UI and control changes in one patch. Implement in this order: + +1. typed `DeviceIdentity` and firmware boot identity announcement; +2. `DeviceIoWorker` + session generation + bounded handshake; +3. serial/firmware `PortOwner` handoff and single-instance lock; +4. remove automatic ROM-probe classification from normal startup; +5. bounded profile transaction with timeout/failure state; +6. USB removal/re-enumeration recovery; +7. lower-rate health monitoring; +8. QML presentation mapped only from `DeviceSupervisor`; +9. deterministic CI regressions; +10. physical acceptance and only then release/merge. + +## Product behavior reference + +OMICRON DANEO Control is used here only as a product-behavior reference: integrated device/software control, coherent status, live observation, and actionable device diagnostics. ARStack must not imitate proprietary protocols or vendor-specific traces. The implementation remains based on ARStack's explicit IEC 61850 and device-control contracts. From 158b16253ccf644fab1583cbf3ced95e428c80de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 13:46:55 +0700 Subject: [PATCH 152/272] docs(studio): audit P0.5 device supervisor migration --- docs/SMV_STUDIO_DEVICE_SUPERVISOR_AUDIT.md | 493 +++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 docs/SMV_STUDIO_DEVICE_SUPERVISOR_AUDIT.md diff --git a/docs/SMV_STUDIO_DEVICE_SUPERVISOR_AUDIT.md b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_AUDIT.md new file mode 100644 index 000000000..1f205476d --- /dev/null +++ b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_AUDIT.md @@ -0,0 +1,493 @@ +# ARStack Studio P0.5 Device Supervisor Audit + +Status: implementation audit and migration plan after the first physical ESP32-P4 firmware/reconnect tests. + +This document complements `SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md`. The architecture document defines the target. This audit records what the current implementation actually does, where it violates the target invariants, and the safest order to migrate without creating a second source of truth. + +## Executive verdict + +The current application can build, package, discover a serial device, flash a verified ESP32-P4 image, reconnect, and in at least one physical test verify the newly flashed ARStack firmware. That proves the firmware packaging and basic serial/flash paths are useful. + +The control plane is not yet production-ready because several independent asynchronous mechanisms still share implicit state: + +- `DeviceController` owns `QSerialPort` directly on the GUI thread; +- `SmartSessionController` owns discovery/update/profile orchestration but also infers state from `DeviceController` booleans and human logs; +- `StudioDeviceController` independently scrapes the same log to decide whether firmware is current; +- `WorkflowBar.qml` derives additional product state and automatically opens recovery dialogs; +- `FirmwareManager` owns an external `espflash` process without an explicit port-ownership handshake with the serial controller; +- profile deployment is a fire-and-wait sequence with no bounded transaction timeout. + +These weaknesses explain the physical symptoms already observed: false firmware-recovery prompts after a successful flash, `Access is denied` around re-enumeration, and `PREPARING` that can remain forever. + +The fix must be a staged control-plane migration, not another collection of timers or popup conditions. + +## Prime migration rule + +**Do not create a new `DeviceSupervisor` beside `SmartSessionController`.** + +`SmartSessionController` is already the closest thing to the product-level session authority. Creating a second supervisor while the old one remains active would violate `AGENTS.md` and make the race surface larger. + +Migration rule: + +1. evolve `SmartSessionController` in-place into the typed supervisor; +2. keep its QML type/name during P0.5 so QML does not need a simultaneous big-bang rewrite; +3. extract serial ownership from `DeviceController` into `DeviceIoWorker` behind the existing controller facade; +4. retire redundant state derivation from `StudioDeviceController` and QML as each typed supervisor field becomes authoritative; +5. only consider a class rename after physical acceptance, when it is a mechanical/API cleanup rather than an architectural change. + +## Current ownership map + +### `DeviceController` + +Current responsibilities are too broad: + +- enumerates ports with `QSerialPortInfo`; +- chooses a recommended candidate; +- owns and opens `QSerialPort`; +- performs the semantic handshake; +- parses identity, profile, state, timing and PTP lines; +- stores device identity fields; +- sends START/STOP/profile/live-setpoint commands; +- owns profile armed/deploying booleans; +- stores diagnostics text; +- interprets transport errors. + +Because the QML `DeviceController` object is instantiated on the GUI thread, `QSerialPort`, parsing callbacks, writes, handshake timers, live flushes and heartbeat activity currently run on the GUI thread. + +### `StudioDeviceController` + +Adds: + +- 20 ms coalesced live-setpoint flush; +- 700 ms session heartbeat; +- firmware-current gating for START and profile deploy; +- a second firmware identity parser that searches `logText`. + +This duplicates firmware truth already derived by `SmartSessionController` and must be removed once typed identity exists. + +### `SmartSessionController` + +Currently owns: + +- 2.5 s discovery watchdog; +- automatic blank-board ROM probing; +- firmware update/reconnect workflow; +- version comparison; +- default-profile synchronization; +- the presentation state string. + +It is therefore the correct object to evolve into the single supervisor, but its current model is a collection of booleans (`updateRequested_`, `blankProbeInFlight_`, `blankBoardDetected_`, `setupError_`, `needsProfileSync_`, `profileSyncInFlight_`) rather than typed orthogonal state. + +### `FirmwareManager` + +Good existing properties that must be preserved: + +- manifest/schema validation; +- ESP32-P4 target verification; +- pre-v3 revision policy; +- SHA-256 verification; +- flash offset validation; +- bounded launch/probe/flash/reset deadlines; +- terminal outcome per operation; +- explicit shutdown/reap. + +Missing architectural boundary: + +- it can start `espflash` immediately after the serial controller calls `QSerialPort::close()`, but there is no explicit `portReleased` acknowledgement/ownership token across the Windows driver boundary. + +### QML + +`WorkflowBar.qml` correctly funnels F5/Start through Smart Session for firmware gating, but QML still derives some independent truth: + +- `Main.qml` computes `canDeploy` / `canStart` from raw device/profile properties; +- edits send directly to `device` whenever `deviceVerified` is true; +- header click can invoke `device.autoDetectAndConnect()` directly; +- recovery dialogs are opened automatically from state changes. + +QML must end as a pure projection/action surface over supervisor-owned product state. + +## P0 blockers found by audit + +### P0-A — human diagnostic log is part of product state + +`DeviceController` parses only product/target/protocol/device ID into typed fields. Firmware version is not part of its typed identity. `SmartSessionController::refreshFirmwareIdentity()` searches the latest `ARSTACK identity` line in `logText`; `StudioDeviceController::currentFirmwareIdentitySeen()` implements the same pattern again. + +Impact: + +- two firmware-version parsers; +- diagnostics truncation/format changes can alter control behavior; +- impossible to attach a boot/session identity to an event; +- stale lines can be confused with current connection state. + +Required fix: one typed `DeviceIdentity` parsed once at the transport boundary. + +### P0-B — handshake is single-shot and timing-sensitive + +Opening a port immediately sends `IDENTIFY`, `SHOW`, and `PROFILE SHOW`. A single one-shot verification timer (about 1.1 s during generic probing, 2.6 s otherwise) closes the port if identity has not appeared. There is no bounded IDENTIFY retry and the firmware does not proactively announce identity when the control task becomes ready. + +Impact: + +- USB CDC re-enumeration / firmware startup timing can create false identity failure; +- installed/current firmware can be classified as unknown; +- the recovery path may then be entered incorrectly. + +Required fix: firmware boot identity announcement plus bounded request/retry handshake. + +### P0-C — normal identity failure can become automatic ROM recovery + +`SmartSessionController` schedules a read-only ROM probe when the application identity is not verified and a candidate port is available. A successful ROM probe proves only that a supported ESP32-P4 boot ROM can be reached; it does not prove application firmware is absent. + +Impact: + +- transient semantic-handshake failure can produce `Firmware required`; +- user can be asked to reinstall firmware that was already flashed correctly. + +Required fix: remove automatic ROM classification from the normal startup path. ROM probing begins only after an explicit operator Recover/Install action from an `unidentified` state. + +### P0-D — serial ownership and firmware-tool ownership are not transactional + +Firmware update currently performs: + +`device.disconnectPort()` -> `firmware.probeTarget()` + +in the same orchestration path. There is no explicit ownership state, release acknowledgement, re-enumeration generation, or driver-settle proof. + +Impact: + +- Windows COM handle races; +- `Access is denied` / permission failures; +- reconnect can target a transient/stale port name; +- success/failure can depend on USB driver timing. + +Required fix: `PortOwner { none, device_session, firmware_tool }` and an acknowledged release/acquire protocol. + +### P0-E — profile synchronization is not a bounded transaction + +`deployProfile()` marks `profileDeploying=true`, sends a command sequence, and waits for a textual committed/armed response. Smart Session clears `profileSyncInFlight_` only on the success condition `profileArmed && !profileDeploying`. + +If the firmware rejects the transaction, the transport drops a response, the COM handle becomes degraded, or no response arrives, `profileSyncInFlight_` can remain true indefinitely. `PREPARING 4I+4V` therefore has no guaranteed terminal transition. + +Required fix: generation-aware profile transaction with deadline and terminal success/failure. + +### P0-F — transport errors are under-classified + +Only `ResourceError` and `DeviceNotFoundError` force disconnect. Other errors on an open serial handle are logged while the current verified/connected state may remain alive. + +Impact: + +- Windows `PermissionError` / access-loss conditions can leave stale verified state; +- profile and live commands can continue to target a broken handle. + +Required fix: structured transport error mapping. Permission/access/read/write failures that invalidate control reliability must retire the session generation and close the link. + +### P0-G — no session generation + +Callbacks and text responses do not carry a connection generation. After reset/reconnect there is no formal barrier preventing late callbacks or cached state from the old serial session from affecting the new one. + +Required fix: monotonic `sessionGeneration`, attached to worker events and transactions. + +### P0-H — USB presence observation is indirect + +Port discovery is refreshed during connection attempts. While a verified port is open, removal detection relies mostly on serial errors. There is no independent device-presence observer and no identity-based reacquisition after COM renumbering. + +Required fix: worker-side bounded presence observation. Use portable `QSerialPortInfo` polling first; optionally add a Windows native device-change adapter later if measurement shows the polling latency is insufficient. + +### P0-I — direct QML/device commands bypass the future supervisor contract + +Setpoint edits, frequency changes, quality updates and some connection actions can call `device` directly. `deviceVerified` alone is not the complete permission to send control traffic. + +Required fix: supervisor provides the actionable command gate/capability context. The device facade may still expose methods for compatibility during migration, but QML must stop deriving control permission itself. + +### P0-J — single-instance ownership is absent + +A second Studio process can contend for the same COM port and produce access errors indistinguishable from driver/re-enumeration failures. + +Required fix: acquire a process-level single-instance lock before starting device workers. The second process should exit cleanly after notifying/foregrounding the existing instance where practical. + +## Invariants that must not change during migration + +1. Public P0 wire scope remains exactly 4I + 4V, 4000 fps reference flow, 1 ASDU, 64-byte sample payload and 16 leaves. +2. `smpSynch` remains honest/unsynchronized (`0`) until measured disciplined-clock evidence exists. +3. Firmware flashing remains user-approved and fail-closed. +4. Wrong chip/revision/hash/manifest/offset remains blocked before write. +5. Output never auto-Starts after boot, reconnect, firmware update or profile restore. +6. Hard loss of the Studio control session while RUNNING remains bounded by the firmware lease (~2.5 s). +7. The realtime publisher hot path receives no desktop worker/logging complexity. +8. Live setpoints retain bounded last-value-wins/coalesced behavior. +9. No unbounded retry, reconnect, worker queue or diagnostics queue is introduced. +10. Normal Studio startup must never write firmware automatically. + +## Migration strategy — seam first, worker second + +A full worker/supervisor rewrite in one patch would combine protocol grammar, threading, USB ownership, firmware recovery, profile transactions and QML changes. That is too broad for the current hardware-learning phase. + +Use the following checkpoints. + +### Checkpoint S1 — typed semantic identity and firmware boot announcement + +Embedded: + +- add per-boot nonce (`boot_id`); +- emit one machine-readable identity announcement only after the control task is ready; +- keep `IDENTIFY` request/response authoritative; +- keep the existing stable EFUSE `device_id`. + +Desktop: + +- extend the one identity parser to capture firmware version, boot ID and capabilities; +- store typed identity on `DeviceController`/transport boundary; +- remove `SmartSessionController::refreshFirmwareIdentity()` log scraping; +- remove `StudioDeviceController::currentFirmwareIdentitySeen()` log scraping; +- compare current firmware from typed identity only. + +Regression: + +- valid current identity; +- legacy identity; +- malformed identity; +- missing firmware field; +- wrong target/protocol; +- stale boot ID/session identity rejection once S2 adds generation. + +Hardware gate: + +- reboot board repeatedly and verify identity appears without requiring exact IDENTIFY timing. + +### Checkpoint S2 — bounded semantic handshake; disable automatic ROM classification + +- retry IDENTIFY at a bounded cadence (target: about 4 attempts across 2-3 s; exact values measured on the board/Windows path); +- identity timeout produces `DEVICE NOT IDENTIFIED`, never `FIRMWARE REQUIRED`; +- remove `maybeScheduleBlankBoardProbe()` from ordinary startup; +- expose explicit `Recover firmware` action only after semantic failure; +- ROM probe remains read-only until explicit recovery action. + +Regression: + +- first IDENTIFY response lost, second succeeds; +- all identity attempts time out -> unidentified, no ROM probe; +- current firmware never opens Install dialog; +- legacy identity opens Update, not Recover. + +### Checkpoint S3 — bounded profile transaction + +Do this before worker extraction because it removes the current `PREPARING forever` release blocker with a small, testable seam. + +- add profile transaction state (`idle/sending/waiting/armed/failed`); +- record previous profile generation; +- start a 3-5 s local-transport deadline (final value chosen from measurement); +- success requires newer committed/armed generation for the current serial session; +- rejection, write failure, timeout or disconnect ends the transaction; +- allow at most one controlled reconnect/resync retry by policy; +- otherwise expose `PROFILE SYNC ERROR` + explicit Retry; +- no boolean may remain in-flight without an owner timer/result. + +Regression: + +- commit succeeds; +- commit rejected; +- response omitted; +- disconnect during transaction; +- late success from retired transaction ignored. + +After S1-S3, produce a short hardware RC. Acceptance: current flashed board must open repeatedly without firmware prompt and must reach bounded READY or a bounded actionable error. Do not wait for worker extraction to validate these control semantics. + +### Checkpoint S4 — extract `DeviceIoWorker` behind the existing facade + +Preserve the `DeviceController` QML-facing shape temporarily, but move all serial I/O into one long-lived worker thread. + +Important construction rule: create the actual `QSerialPort` and worker timers in the worker thread after `moveToThread`; do not create a UI-thread `QSerialPort` and then accidentally leave its affinity behind. + +Worker responsibilities: + +- port enumeration/presence polling; +- open/close/read/write; +- handshake timers; +- machine-line parsing or structured events; +- bounded command queue; +- quiet heartbeat/health requests; +- release acknowledgement. + +UI/supervisor responsibilities: + +- no direct `QSerialPort` calls; +- no waits/blocking joins on normal UI operations; +- typed event consumption only. + +Queue policy: + +- control/config commands: small ordered bounded queue; +- live setpoints: existing channel-wise last-value-wins coalescing; +- telemetry: coalesce stale values; +- exclusive profile/firmware operations block unrelated configuration mutation. + +### Checkpoint S5 — session generation + explicit port owner + FirmwareWorker boundary + +- add monotonic `sessionGeneration` in Smart Session/supervisor; +- every worker event carries generation; +- retired-generation events are ignored; +- implement `PortOwner`; +- firmware process may start only after worker emits `portReleased(generation)`; +- after reset, do not trust COM number alone; reacquire candidate then prove semantic device ID/boot ID; +- success popup remains after semantic firmware verification, never after flash exit code alone. + +Move `FirmwareManager` process execution behind a worker/service boundary at this checkpoint. Preserve its existing manifest/hash/revision/deadline code rather than rewriting it. + +Regression: + +- firmware tool refused while device owns port; +- explicit release -> firmware tool allowed; +- stale reconnect event ignored; +- COM number changes across reset; +- PermissionError retires session; +- one terminal firmware result per attempt. + +### Checkpoint S6 — USB/hotplug + health + single instance + +- bounded worker-side `QSerialPortInfo` presence scan (initial target 500-750 ms, measured CPU impact); +- immediate link retirement on fatal serial error; +- device ID becomes physical identity after handshake, COM name only transport locator; +- unplug READY -> DISCONNECTED; +- unplug RUNNING -> DISCONNECTED, firmware lease owns fail-safe STOP; +- replug -> identity -> profile sync -> READY, never auto-Start; +- add single-instance process lock; +- add lower-rate positive health request separate from 700 ms safety heartbeat. + +Health policy must be bounded, e.g. a small number of missed replies before `online -> degraded -> reconnecting`; exact numbers come from physical latency measurement, not arbitrary sleeps. + +### Checkpoint S7 — make QML presentation-only + +Once typed supervisor state is complete: + +- QML does not call `autoDetectAndConnect()` directly; +- QML does not derive firmware truth from `deviceVerified` or logs; +- QML does not derive `canStart` / `canDeploy` independently; +- all Start/Stop/Recover/Retry actions enter supervisor commands; +- dialogs are opened from actionable supervisor state, with no repeated automatic popup after a terminal failure; +- Advanced/Diagnostics display worker/supervisor diagnostics but cannot mutate hidden state directly. + +## Typed state target inside existing SmartSessionController + +During P0.5 keep the public class but replace the boolean web with a context resembling: + +```text +DeviceSessionContext + generation + physicalState + transportState + identityState + firmwareState + profileState + outputState + portOwner + candidatePort + verifiedDeviceId + currentBootId + lastErrorCode +``` + +Presentation strings are derived in one function from this context. QML receives both concise presentation and typed properties where needed. + +## Error model + +Introduce one lightweight machine-readable error enum for the control plane; do not create separate unrelated error frameworks in each class. + +Minimum categories: + +```text +None +PortMissing +PortBusy +PermissionDenied +OpenFailed +ReadFailed +WriteFailed +IdentityTimeout +IdentityMalformed +WrongTarget +ProtocolUnsupported +FirmwareLegacy +FirmwareProbeFailed +FirmwareFlashFailed +FirmwareVerifyFailed +ProfileRejected +ProfileTimeout +HealthTimeout +Cancelled +``` + +Human strings are formatted outside the worker/hot path. Diagnostics may include OS error text as context, but state transitions use error codes. + +## CI strategy + +The current CLI regressions are useful but mostly test parser/package/lifecycle contracts. Add a dedicated deterministic device-session test target rather than stuffing every state case into `main.cpp`. + +Recommended structure: + +- `arstack_studio_session_tests` using Qt Test or a small deterministic harness; +- scripted/fake transport event source (production + test seam is justified because the real worker is asynchronous and hardware-independent state transitions require deterministic testing); +- no actual COM/ESP32 required for state tests. + +CI must lock at least: + +1. current identity -> Ready path; +2. legacy identity -> Update path; +3. identity timeout -> Unidentified, never Install; +4. explicit Recover -> ROM probe allowed; +5. PermissionDenied -> session retired; +6. stale generation -> ignored; +7. profile timeout/rejection -> terminal error; +8. unplug READY/RUNNING -> Disconnected; +9. replug -> Ready, never auto-Start; +10. firmware tool cannot own port before release acknowledgement; +11. shutdown retires worker/thread/process; +12. second process lock rejects duplicate instance. + +Keep existing reference-template, firmware manifest/hash/revision, lifecycle, headless QML and Windows package gates. + +## Performance/resource contract + +Measure rather than assume. + +Targets to record during P0.5 acceptance: + +- worker presence-poll CPU while idle; +- UI event-loop latency during serial telemetry and live edits; +- command queue high-water mark; +- dropped/coalesced live-setpoint count if instrumented; +- identity/reconnect latency distribution across at least repeated board resets; +- profile sync duration; +- firmware release/reacquire latency; +- ESP32 task stack high-water marks after identity/health additions; +- sustained 4000 fps publisher missed-slot/TX-failure telemetry. + +No worker design may add allocation/log formatting to the ESP32 realtime transmit path. + +## Hardware acceptance for the next RC + +The next hardware RC is not accepted merely because firmware flashes. + +Required sequence: + +1. flash once; +2. close/reopen Studio 10 times -> 10/10 semantic identity, zero install prompts; +3. each open reaches READY or a bounded actionable error, never infinite Connecting/Preparing; +4. profile auto-sync succeeds repeatedly; +5. Start -> analyzer observes expected SV; +6. live magnitude/phase/frequency edit changes the transmitted stream; +7. Stop -> output stops; +8. unplug READY -> immediate Disconnected; +9. unplug RUNNING -> Studio Disconnected and firmware lease stops SV within bound; +10. replug -> auto identity/profile/Ready, no auto-Start; +11. reset board repeatedly including COM renumbering -> recover by device identity; +12. legacy firmware -> exactly one Update workflow; +13. blank board -> explicit Recover -> flash -> verified current identity; +14. hard-kill Studio while running -> firmware lease stops output; +15. close Studio normally -> no orphan process/thread/firmware tool. + +## Immediate implementation decision + +Do **S1 -> S2 -> S3 first**. These checkpoints directly address the failures seen on the physical board and are small enough to validate independently. + +Only after their deterministic tests and short hardware RC pass should serial ownership move to a worker (S4/S5). This avoids debugging protocol grammar, profile transactions, USB thread affinity and Windows port ownership simultaneously. + +No further visual polish or full OBS docking work should be mixed into these checkpoints. From 1e02dedc1627ea9d20f3d4a56bae3cfcb4b0ca4c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:18:51 +0700 Subject: [PATCH 153/272] feat(studio): make device identity a typed contract --- apps/arstack_studio/src/DeviceController.hpp | 57 +++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index db7a2bb71..01854db63 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -7,6 +7,24 @@ #include #include +struct DeviceIdentity final { + QString product; + QString target; + QString protocolVersion; + QString deviceId; + QString firmwareVersion; + QString bootId; + QStringList capabilities; + + [[nodiscard]] bool empty() const noexcept { + return product.isEmpty() && target.isEmpty() && protocolVersion.isEmpty() && + deviceId.isEmpty() && firmwareVersion.isEmpty() && bootId.isEmpty() && + capabilities.isEmpty(); + } + + friend bool operator==(const DeviceIdentity&, const DeviceIdentity&) = default; +}; + class DeviceController : public QObject { Q_OBJECT Q_PROPERTY(QStringList ports READ ports NOTIFY portsChanged) @@ -14,9 +32,13 @@ class DeviceController : public QObject { Q_PROPERTY(QString discoveryStatus READ discoveryStatus NOTIFY discoveryChanged) Q_PROPERTY(bool discovering READ discovering NOTIFY discoveryChanged) Q_PROPERTY(bool deviceVerified READ deviceVerified NOTIFY deviceVerifiedChanged) - Q_PROPERTY(QString deviceProduct READ deviceProduct NOTIFY deviceVerifiedChanged) - Q_PROPERTY(QString deviceId READ deviceId NOTIFY deviceVerifiedChanged) - Q_PROPERTY(QString protocolVersion READ protocolVersion NOTIFY deviceVerifiedChanged) + Q_PROPERTY(QString deviceProduct READ deviceProduct NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString deviceTarget READ deviceTarget NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString deviceId READ deviceId NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString protocolVersion READ protocolVersion NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged) + Q_PROPERTY(QStringList capabilities READ capabilities NOTIFY deviceIdentityChanged) Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) @@ -48,8 +70,13 @@ class DeviceController : public QObject { [[nodiscard]] bool discovering() const noexcept; [[nodiscard]] bool deviceVerified() const noexcept; [[nodiscard]] QString deviceProduct() const; + [[nodiscard]] QString deviceTarget() const; [[nodiscard]] QString deviceId() const; [[nodiscard]] QString protocolVersion() const; + [[nodiscard]] QString firmwareVersion() const; + [[nodiscard]] QString bootId() const; + [[nodiscard]] QStringList capabilities() const; + [[nodiscard]] DeviceIdentity deviceIdentity() const; [[nodiscard]] bool connected() const noexcept; [[nodiscard]] bool running() const noexcept; [[nodiscard]] QString portName() const; @@ -72,6 +99,15 @@ class DeviceController : public QObject { [[nodiscard]] QString ptpSyncSent() const; [[nodiscard]] QString ptpTxFailures() const; + // S1 semantic identity contract. The parser accepts the immediately + // preceding ARStack v1 identity grammar without boot_id so an installed + // pre-S1 image is recognized as legacy/updateable rather than "blank". + // Current Studio firmware requires boot_id and the mandatory capabilities. + [[nodiscard]] static bool parseIdentityLine(const QString& line, DeviceIdentity& identity); + [[nodiscard]] static bool identitySupportsCurrentContract( + const DeviceIdentity& identity, + const QString& expectedFirmwareVersion); + Q_INVOKABLE void refreshPorts(); Q_INVOKABLE bool autoDetectAndConnect(); Q_INVOKABLE bool connectPort(const QString& portName); @@ -97,8 +133,6 @@ class DeviceController : public QObject { Q_INVOKABLE bool stopPtp(); Q_INVOKABLE bool configurePtp(const QVariantMap& profile); - // P2 timing role selection is stopped-only for PTP, but independent of the - // SV publisher. The firmware owns validation and refuses live role mutation. Q_INVOKABLE bool setPtpRole(const QString& requestedRole) { const QString role = requestedRole.trimmed().toUpper(); static const QStringList validRoles{ @@ -116,9 +150,6 @@ class DeviceController : public QObject { return sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role)); } - // P1.75 laboratory SV synchronization stimulus. These commands are - // intentionally separate from PTP lock detection: AUTO remains conservative, - // while 0/1/2 are explicit simulated wire states for relay testing. Q_INVOKABLE bool sendSmpSynchShow() { return sendCommand(QStringLiteral("PROFILE SHOW")); } @@ -139,6 +170,7 @@ class DeviceController : public QObject { void portsChanged(); void discoveryChanged(); void deviceVerifiedChanged(); + void deviceIdentityChanged(); void connectedChanged(); void runningChanged(); void lastErrorChanged(); @@ -149,9 +181,6 @@ class DeviceController : public QObject { void deviceMessage(const QString& message); protected: - // Keep high-rate/session-control traffic out of the human diagnostics log. - // This is intentionally protected so StudioDeviceController can maintain a - // firmware session lease without making the generic controller API public. bool sendQuietCommand(const QString& command) { if (!serial_.isOpen()) return false; const QByteArray bytes = command.toUtf8() + '\n'; @@ -166,6 +195,8 @@ class DeviceController : public QObject { bool connectPortInternal(const QString& portName, bool automatic); bool tryNextProbe(); bool sendCommand(const QString& command); + void applyIdentity(DeviceIdentity identity); + void clearIdentity(); void markDeviceVerified(); void setDiscoveryState(const QString& status, bool active); void setRunning(bool value); @@ -183,9 +214,7 @@ class DeviceController : public QObject { QString recommendedPort_; QStringList probeQueue_; QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; - QString deviceProduct_; - QString deviceId_; - QString protocolVersion_; + DeviceIdentity identity_; QByteArray pendingRx_; QString lastError_; QString logText_; From 704d327de4cc359731d4baa674fc40f66d795bfd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:21:08 +0700 Subject: [PATCH 154/272] feat(studio): parse semantic device identity once --- apps/arstack_studio/src/DeviceController.cpp | 90 ++++++++++++++++---- 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 4e7aa23a4..2ccab2fe2 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -29,7 +29,10 @@ const QRegularExpression kProfileArmedExpression{ QStringLiteral("PROFILE armed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kIdentityExpression{ - QStringLiteral("ARSTACK identity product=([A-Z0-9_-]+) target=ESP32-P4 protocol=(\\d+) device_id=([A-Fa-f0-9]{12})"), + QStringLiteral( + "^ARSTACK identity product=([A-Z0-9_-]+) target=([A-Z0-9_-]+) protocol=(\\d+) " + "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\-]+) " + "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\-]+)$"), QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kPtpStatusExpression{ QStringLiteral("PTP status=(RUNNING|STOPPED) Announce=(\\d+) Sync=(\\d+) FollowUp=(\\d+) PdelayFrames=(\\d+) TXfail=(\\d+)"), @@ -111,9 +114,14 @@ QString DeviceController::recommendedPort() const { return recommendedPort_; } QString DeviceController::discoveryStatus() const { return discoveryStatus_; } bool DeviceController::discovering() const noexcept { return discovering_; } bool DeviceController::deviceVerified() const noexcept { return deviceVerified_; } -QString DeviceController::deviceProduct() const { return deviceProduct_; } -QString DeviceController::deviceId() const { return deviceId_; } -QString DeviceController::protocolVersion() const { return protocolVersion_; } +QString DeviceController::deviceProduct() const { return identity_.product; } +QString DeviceController::deviceTarget() const { return identity_.target; } +QString DeviceController::deviceId() const { return identity_.deviceId; } +QString DeviceController::protocolVersion() const { return identity_.protocolVersion; } +QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; } +QString DeviceController::bootId() const { return identity_.bootId; } +QStringList DeviceController::capabilities() const { return identity_.capabilities; } +DeviceIdentity DeviceController::deviceIdentity() const { return identity_; } bool DeviceController::connected() const noexcept { return serial_.isOpen(); } bool DeviceController::running() const noexcept { return running_; } QString DeviceController::portName() const { return serial_.portName(); } @@ -136,6 +144,52 @@ QString DeviceController::ptpAnnounceSent() const { return ptpAnnounceSent_; } QString DeviceController::ptpSyncSent() const { return ptpSyncSent_; } QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } +bool DeviceController::parseIdentityLine(const QString& line, DeviceIdentity& identity) { + const auto match = kIdentityExpression.match(line.trimmed()); + if (!match.hasMatch()) return false; + + DeviceIdentity parsed; + parsed.product = match.captured(1).toUpper(); + parsed.target = match.captured(2).toUpper(); + parsed.protocolVersion = match.captured(3); + parsed.deviceId = match.captured(4).toUpper(); + parsed.firmwareVersion = match.captured(5); + parsed.bootId = match.captured(6).toUpper(); + parsed.capabilities = match.captured(7).split(QLatin1Char(','), Qt::SkipEmptyParts); + for (QString& capability : parsed.capabilities) capability = capability.trimmed().toUpper(); + parsed.capabilities.removeDuplicates(); + + bool protocolOk = false; + static_cast(parsed.protocolVersion.toUInt(&protocolOk)); + if (!protocolOk || parsed.product != QStringLiteral("SMV-INJECTOR") || + parsed.target != QStringLiteral("ESP32-P4") || parsed.capabilities.isEmpty()) { + return false; + } + + identity = std::move(parsed); + return true; +} + +bool DeviceController::identitySupportsCurrentContract( + const DeviceIdentity& identity, + const QString& expectedFirmwareVersion) { + static const QStringList requiredCapabilities{ + QStringLiteral("SMV-4I4V"), + QStringLiteral("LIVE-SETPOINTS"), + QStringLiteral("SESSION-LEASE")}; + if (identity.product != QStringLiteral("SMV-INJECTOR") || + identity.target != QStringLiteral("ESP32-P4") || + identity.protocolVersion != QStringLiteral("1") || + identity.firmwareVersion != expectedFirmwareVersion || + identity.bootId.size() != 16) { + return false; + } + for (const QString& required : requiredCapabilities) { + if (!identity.capabilities.contains(required, Qt::CaseInsensitive)) return false; + } + return true; +} + void DeviceController::refreshPorts() { QList candidates; for (const auto& info : QSerialPortInfo::availablePorts()) { @@ -188,9 +242,6 @@ bool DeviceController::autoDetectAndConnect() { } if (ports_.isEmpty()) return false; - // Windows often exposes ESP USB CDC as the generic "USB Serial Device". - // Probe one port at a time with read-only SHOW commands and trust only the - // firmware-specific response grammar parsed by processLine(). probeQueue_ = ports_; genericProbeActive_ = true; return tryNextProbe(); @@ -220,6 +271,7 @@ bool DeviceController::connectPortInternal(const QString& portName, const bool a } automaticConnection_ = automatic; deviceVerified_ = false; + clearIdentity(); setDiscoveryState(QStringLiteral("Verifying ARStack injector identity..."), true); serial_.setPortName(portName.trimmed()); @@ -274,9 +326,7 @@ void DeviceController::disconnectPort() { resetTelemetry(); const bool wasVerified = deviceVerified_; deviceVerified_ = false; - deviceProduct_.clear(); - deviceId_.clear(); - protocolVersion_.clear(); + clearIdentity(); discovering_ = false; automaticConnection_ = false; ptpAvailable_ = false; @@ -513,6 +563,18 @@ void DeviceController::setDiscoveryState(const QString& status, const bool activ emit discoveryChanged(); } +void DeviceController::applyIdentity(DeviceIdentity identity) { + if (identity_ == identity) return; + identity_ = std::move(identity); + emit deviceIdentityChanged(); +} + +void DeviceController::clearIdentity() { + if (identity_.empty()) return; + identity_ = {}; + emit deviceIdentityChanged(); +} + void DeviceController::markDeviceVerified() { if (deviceVerified_) return; verificationTimer_.stop(); @@ -590,11 +652,9 @@ void DeviceController::processLine(const QString& rawLine) { emit profileStateChanged(); } - match = kIdentityExpression.match(line); - if (match.hasMatch()) { - deviceProduct_ = match.captured(1); - protocolVersion_ = match.captured(2); - deviceId_ = match.captured(3).toUpper(); + DeviceIdentity parsedIdentity; + if (parseIdentityLine(line, parsedIdentity)) { + applyIdentity(std::move(parsedIdentity)); markDeviceVerified(); } From f522e48bc94b5d693d3e8df1c9876173cfcf6e50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:21:56 +0700 Subject: [PATCH 155/272] refactor(studio): gate control from typed identity --- .../src/StudioDeviceController.hpp | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index d028ee406..44f1c1dc3 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -5,7 +5,6 @@ #include #include -#include #include #include @@ -51,10 +50,9 @@ class StudioDeviceController : public DeviceController { } ensureSessionHeartbeat(); }); - connect(this, &DeviceController::logTextChanged, this, [this] { - // The semantic version field arrives on the same identity line that - // verifies the device. Start the lease only for current firmware so - // legacy builds are not spammed with an unknown command. + connect(this, &DeviceController::deviceIdentityChanged, this, [this] { + // S1: heartbeat/control permission follows the typed semantic + // identity only. Human diagnostics text is never product state. ensureSessionHeartbeat(); }); @@ -70,17 +68,8 @@ class StudioDeviceController : public DeviceController { } [[nodiscard]] bool currentFirmwareIdentitySeen() const { - const QString log = logText(); - const qsizetype identityPos = log.lastIndexOf(QStringLiteral("ARSTACK identity"), -1, Qt::CaseInsensitive); - if (identityPos < 0) return false; - qsizetype lineEnd = log.indexOf(QLatin1Char('\n'), identityPos); - if (lineEnd < 0) lineEnd = log.size(); - const QString identityLine = log.mid(identityPos, lineEnd - identityPos); - static const QRegularExpression versionExpression{ - QStringLiteral(R"(\bfirmware=([0-9A-Za-z._+\-]+))"), - QRegularExpression::CaseInsensitiveOption}; - const auto match = versionExpression.match(identityLine); - return match.hasMatch() && match.captured(1) == QStringLiteral(ARSTACK_STUDIO_VERSION); + return DeviceController::identitySupportsCurrentContract( + deviceIdentity(), QStringLiteral(ARSTACK_STUDIO_VERSION)); } Q_INVOKABLE bool start() override { @@ -88,7 +77,7 @@ class StudioDeviceController : public DeviceController { emit deviceMessage(QStringLiteral("Connect and verify the ARStack ESP32-P4 before Start.")); return false; } - if (protocolVersion() != QStringLiteral("1") || !currentFirmwareIdentitySeen()) { + if (!currentFirmwareIdentitySeen()) { emit deviceMessage(QStringLiteral("Firmware update required before Start.")); return false; } @@ -97,9 +86,6 @@ class StudioDeviceController : public DeviceController { return false; } - // Refresh the firmware lease synchronously so a STOP -> immediate START - // cannot have a short unprotected RUN window while waiting for the next - // periodic heartbeat tick. if (!sendQuietCommand(QStringLiteral("HEARTBEAT"))) { emit deviceMessage(QStringLiteral("Control session could not be established before Start.")); return false; @@ -172,7 +158,7 @@ class StudioDeviceController : public DeviceController { } Q_INVOKABLE bool deployProfile(const QVariantMap& profile) override { - if (protocolVersion() != QStringLiteral("1") || !currentFirmwareIdentitySeen()) { + if (!currentFirmwareIdentitySeen()) { emit deviceMessage(QStringLiteral("Firmware update required before profile synchronization.")); return false; } From 4c99c5dcd033f937c4666cbeb6ce9be7a1fc6830 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:23:15 +0700 Subject: [PATCH 156/272] refactor(studio): consume typed firmware identity --- .../src/SmartSessionController.cpp | 41 +++++-------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 09f77ac27..f2ca67ba4 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -6,7 +6,6 @@ #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" -#include #include #include @@ -193,8 +192,6 @@ void SmartSessionController::start() { if (profiles_ != nullptr) static_cast(ensureDefaultProfile()); discoveryTimer_.start(); - // Do not make the operator wait for the periodic hot-plug watchdog. The - // first identity attempt starts as soon as the QML component is complete. QTimer::singleShot(0, this, [this] { if (!started_ || device_ == nullptr || updateRequested_) return; if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { @@ -321,6 +318,11 @@ void SmartSessionController::reconnectDeviceSignals() { } reconcile(); }); + connect(device_, &DeviceController::deviceIdentityChanged, this, [this] { + refreshFirmwareIdentity(); + emit stateChanged(); + reconcile(); + }); connect(device_, &DeviceController::connectedChanged, this, [this] { reconcile(); maybeScheduleBlankBoardProbe(); @@ -341,10 +343,6 @@ void SmartSessionController::reconnectDeviceSignals() { reconcile(); maybeScheduleBlankBoardProbe(); }); - connect(device_, &DeviceController::logTextChanged, this, [this] { - refreshFirmwareIdentity(); - reconcile(); - }); connect(device_, &DeviceController::runningChanged, this, [this] { if (updateRequested_ && updateStage_ == UpdateStage::stopping && device_ != nullptr && !device_->running()) { @@ -389,17 +387,10 @@ void SmartSessionController::reconnectFirmwareSignals() { blankProbeInFlight_ = false; const bool samePort = firmware_->selectedPort() == blankBoardPort_; if (samePort && firmware_->targetVerified()) { - // Read-only ROM identity proved a supported P4. The user still - // explicitly approves installation in the normal setup dialog. blankBoardDetected_ = true; setupError_ = false; setupErrorStatus_.clear(); } else if (samePort && firmware_->bootloaderHelpNeeded()) { - // A unique serial candidate exists but ROM did not answer yet. - // Treat this as a firmware-setup candidate, not as verified P4; - // beginFirmwareInstall() re-probes and cannot write until target - // verification succeeds. This keeps novice recovery actionable - // without weakening the flash safety boundary. blankBoardDetected_ = true; setupError_ = false; setupErrorStatus_.clear(); @@ -566,24 +557,14 @@ void SmartSessionController::setPresentation( } void SmartSessionController::refreshFirmwareIdentity() { - if (device_ == nullptr || !device_->deviceVerified()) return; - const QString log = device_->logText(); - const qsizetype identityPos = log.lastIndexOf(QStringLiteral("ARSTACK identity"), -1, Qt::CaseInsensitive); - if (identityPos < 0) return; - - qsizetype lineEnd = log.indexOf(QLatin1Char('\n'), identityPos); - if (lineEnd < 0) lineEnd = log.size(); - const QString identityLine = log.mid(identityPos, lineEnd - identityPos); - static const QRegularExpression versionExpression{ - QStringLiteral(R"(\bfirmware=([0-9A-Za-z._+\-]+))"), - QRegularExpression::CaseInsensitiveOption}; - const auto match = versionExpression.match(identityLine); - deviceFirmwareVersion_ = match.hasMatch() ? match.captured(1) : QString{}; + deviceFirmwareVersion_ = + (device_ != nullptr && device_->deviceVerified()) ? device_->firmwareVersion() : QString{}; } bool SmartSessionController::firmwareIsCurrent() const { - return device_ != nullptr && device_->protocolVersion() == QStringLiteral("1") && - !deviceFirmwareVersion_.isEmpty() && deviceFirmwareVersion_ == expectedFirmwareVersion(); + return device_ != nullptr && device_->deviceVerified() && + DeviceController::identitySupportsCurrentContract( + device_->deviceIdentity(), expectedFirmwareVersion()); } void SmartSessionController::reconcile() { @@ -678,7 +659,7 @@ void SmartSessionController::reconcile() { ? QStringLiteral("legacy/unknown firmware") : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); latchFirmwareFailure(QStringLiteral( - "Firmware was written, but reconnect verification reported %1 instead of v%2. Retry firmware setup explicitly.") + "Firmware was written, but reconnect verification reported %1 instead of the current semantic identity contract for v%2. Retry firmware setup explicitly.") .arg(observed, expectedFirmwareVersion())); emit firmwareUpdateFinished(false); setPresentation(QStringLiteral("SETUP ERROR"), setupErrorStatus_, false, false); From e699a6bc3ffca250c41412b633167fbbb7a2ea84 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:24:31 +0700 Subject: [PATCH 157/272] feat(firmware): announce typed boot identity --- .../main/live_control.cpp | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index a2d3fe2e8..553de97fd 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -7,6 +7,7 @@ #include "esp_app_desc.h" #include "esp_log.h" #include "esp_mac.h" +#include "esp_random.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -49,6 +50,7 @@ std::atomic g_control_heartbeat_seen{false}; std::atomic g_control_lease_active{false}; std::atomic g_last_heartbeat_ms{0U}; esp_timer_handle_t g_control_lease_timer{nullptr}; +std::uint64_t g_boot_id{0U}; void wake_publisher() noexcept { const auto task = g_publisher_task.load(std::memory_order_acquire); @@ -150,8 +152,6 @@ void record_control_heartbeat() noexcept { bool arm_control_lease_if_fresh() noexcept { if (!heartbeat_is_fresh()) { - // No current Studio heartbeat: preserve the legacy bench/manual START - // behavior. Only a Studio-owned session is required to carry a lease. g_control_lease_active.store(false, std::memory_order_release); return true; } @@ -240,11 +240,12 @@ void print_identity() noexcept { ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE" : "SMV-4I4V,LIVE-SETPOINTS"; ESP_LOGI(kTag, - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s capabilities=%s", + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s boot_id=%016llX capabilities=%s", static_cast(device_id[0]), static_cast(device_id[1]), static_cast(device_id[2]), static_cast(device_id[3]), static_cast(device_id[4]), static_cast(device_id[5]), version, + static_cast(g_boot_id), capabilities); } @@ -401,8 +402,6 @@ void handle_line(char* line) noexcept { if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: HEARTBEAT"); } else { - // Intentionally silent: Studio sends this frequently while it owns - // the control session, so it must not create serial/log noise. record_control_heartbeat(); } return; @@ -545,7 +544,6 @@ void handle_line(char* line) noexcept { auto state = g_signal_bank.snapshot(); auto& channel = state.channels[*channel_index]; - if (std::strcmp(command, "SET") == 0) { char* rms_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); char* phase_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); @@ -613,6 +611,11 @@ void live_control_initialize( g_start_request.store(false, std::memory_order_release); clear_control_session(); + const std::uint64_t random_high = static_cast(esp_random()); + const std::uint64_t random_low = static_cast(esp_random()); + g_boot_id = (random_high << 32U) | random_low; + if (g_boot_id == 0U) g_boot_id = 1U; + if (g_control_lease_timer == nullptr) { esp_timer_create_args_t timer_args{}; timer_args.callback = &control_lease_timer_callback; @@ -654,6 +657,12 @@ void live_control_task(void*) noexcept { print_state(); ESP_LOGI(kTag, "Console ready: type a complete command, then press Enter."); + // S1 startup announcement: once the control task is ready, advertise the + // same machine-readable identity returned by IDENTIFY. Studio must still + // use explicit request/response as authoritative, but it no longer depends + // on landing the first request in a narrow USB re-enumeration window. + print_identity(); + std::array line{}; std::size_t length = 0U; bool discard_until_eol = false; From 180063a7ee322bc894d4c546a8fc17e39889e634 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:24:48 +0700 Subject: [PATCH 158/272] build(firmware): declare boot identity RNG dependency --- embedded/esp32p4_smv_injector/main/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index 13d29b377..48e478b6b 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -17,7 +17,7 @@ idf_component_register( ${AR_PTP_LAB_SRCS} INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos - PRIV_REQUIRES esp_app_format) + PRIV_REQUIRES esp_app_format esp_hw_support) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) From fc54809f2540c3fa59897f3951e7e02395d3116f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:25:47 +0700 Subject: [PATCH 159/272] test(studio): lock typed identity S1 contract --- apps/arstack_studio/src/main.cpp | 84 +++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index b3a1a992e..e2ac4d2c7 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -42,10 +42,6 @@ class PrimaryWindowCloseFilter final : public QObject { bool eventFilter(QObject* watched, QEvent* event) override { if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { closeObserved_ = true; - // quit() marks the running event loop for termination; it does not - // destroy the window synchronously. Returning false therefore still - // lets Main.qml's onClosing handler issue its best-effort STOP. - // Auxiliary QML windows can no longer keep the process alive. app_->quit(); } return QObject::eventFilter(watched, event); @@ -154,8 +150,6 @@ int checkFirmwareContract(int argc, char* argv[]) { return 4; } - // Shutdown must be safe and idempotent; this catches lifecycle regressions - // in every package-contract run without launching external firmware tools. firmware.shutdown(); firmware.shutdown(); @@ -167,6 +161,70 @@ int checkFirmwareContract(int argc, char* argv[]) { int checkP0ControllerPolicy(int argc, char* argv[]) { QCoreApplication app(argc, argv); + + DeviceIdentity currentIdentity; + const QString currentLine = QStringLiteral( + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE") + .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); + const bool currentParsed = DeviceController::parseIdentityLine(currentLine, currentIdentity); + const bool currentAccepted = currentParsed && + DeviceController::identitySupportsCurrentContract( + currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION)); + + DeviceIdentity legacyIdentity; + const QString legacyLine = QStringLiteral( + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=%1 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE") + .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); + const bool legacyParsed = DeviceController::parseIdentityLine(legacyLine, legacyIdentity); + const bool legacyRejectedAsCurrent = legacyParsed && + !DeviceController::identitySupportsCurrentContract( + legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION)); + + DeviceIdentity protocolLegacy; + const bool protocolLegacyParsed = DeviceController::parseIdentityLine( + QStringLiteral( + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=0 " + "device_id=A1B2C3D4E5F6 firmware=0.0.9 capabilities=SMV-4I4V,LIVE-SETPOINTS"), + protocolLegacy) && + !DeviceController::identitySupportsCurrentContract( + protocolLegacy, QStringLiteral(ARSTACK_STUDIO_VERSION)); + + DeviceIdentity rejectedIdentity; + const bool rejectsWrongTarget = !DeviceController::parseIdentityLine( + QStringLiteral( + "ARSTACK identity product=SMV-INJECTOR target=ESP32-S3 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + rejectedIdentity); + const bool rejectsMissingFirmware = !DeviceController::parseIdentityLine( + QStringLiteral( + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + rejectedIdentity); + const bool rejectsMalformedBoot = !DeviceController::parseIdentityLine( + QStringLiteral( + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=NOT-A-BOOT-ID " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + rejectedIdentity); + + if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || + !rejectsWrongTarget || !rejectsMissingFirmware || !rejectsMalformedBoot) { + qCritical().noquote() + << "S1 identity contract: FAIL" + << "current=" << currentAccepted + << "legacy=" << legacyRejectedAsCurrent + << "protocol-legacy=" << protocolLegacyParsed + << "wrong-target=" << rejectsWrongTarget + << "missing-firmware=" << rejectsMissingFirmware + << "malformed-boot=" << rejectsMalformedBoot; + return 11; + } + StudioDeviceController device; if (device.start()) { qCritical().noquote() << "P0 controller policy: FAIL · unverified device was allowed to START"; @@ -176,7 +234,8 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { qCritical().noquote() << "P0 controller policy: FAIL · incompatible/unverified device accepted deploy"; return 6; } - qInfo().noquote() << "P0 controller policy: PASS · unverified START/DEPLOY fail closed"; + qInfo().noquote() + << "P0 controller policy: PASS · typed S1 identity + unverified START/DEPLOY fail closed"; return 0; } } // namespace @@ -229,9 +288,6 @@ int main(int argc, char* argv[]) { PrimaryWindowCloseFilter primaryCloseFilter{&app}; if (mainWindow != nullptr) { - // Auxiliary QML windows deliberately stay instantiated for fast reuse. - // The primary ApplicationWindow therefore owns process lifetime: its - // close event retires the event loop even when hidden docks still exist. mainWindow->installEventFilter(&primaryCloseFilter); } @@ -249,9 +305,6 @@ int main(int argc, char* argv[]) { return; } - // Windows hosted runners have no interactive desktop. Deliver the - // authoritative Qt close event directly instead of depending on a - // native WM_CLOSE round trip that the runner cannot guarantee. QCloseEvent closeEvent; QCoreApplication::sendEvent(lifecycleTarget.data(), &closeEvent); if (!primaryCloseFilter.closeObserved()) { @@ -260,11 +313,6 @@ int main(int argc, char* argv[]) { return; } - // A GitHub-hosted Windows session can keep the synthetic close - // dispatch nested even after QCoreApplication::quit() has been - // requested from the production filter. Once the authoritative - // filter has observed the close, explicitly finish only the test - // harness. Production still relies on the same filter's quit(). QCoreApplication::exit(0); }); QTimer::singleShot(3500, &app, [] { From 6af49891cd0b3e31898cdd9b2d033d436696cbe4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:28:04 +0700 Subject: [PATCH 160/272] fix(studio): parse identity inside ESP-IDF log framing --- apps/arstack_studio/src/DeviceController.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 2ccab2fe2..ed4ea3949 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace { constexpr qint32 kBaudRate = 115200; @@ -30,9 +31,9 @@ const QRegularExpression kProfileArmedExpression{ QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kIdentityExpression{ QStringLiteral( - "^ARSTACK identity product=([A-Z0-9_-]+) target=([A-Z0-9_-]+) protocol=(\\d+) " + "ARSTACK identity product=([A-Z0-9_-]+) target=([A-Z0-9_-]+) protocol=(\\d+) " "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\-]+) " - "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\-]+)$"), + "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\-]+)"), QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kPtpStatusExpression{ QStringLiteral("PTP status=(RUNNING|STOPPED) Announce=(\\d+) Sync=(\\d+) FollowUp=(\\d+) PdelayFrames=(\\d+) TXfail=(\\d+)"), From dd3514eef95bb2e76e671899389addc7b71fc589 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:31:03 +0700 Subject: [PATCH 161/272] test(studio): exercise framed S1 identity and capability gate --- apps/arstack_studio/src/main.cpp | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index e2ac4d2c7..4681fce10 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -163,8 +163,11 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QCoreApplication app(argc, argv); DeviceIdentity currentIdentity; + // Match the real ESP-IDF serial framing. The semantic parser must extract + // the contract from the log prefix without making the human log itself the + // source of product state. const QString currentLine = QStringLiteral( - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "I (412) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF " "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); @@ -175,7 +178,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { DeviceIdentity legacyIdentity; const QString legacyLine = QStringLiteral( - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "I (417) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=%1 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool legacyParsed = DeviceController::parseIdentityLine(legacyLine, legacyIdentity); @@ -186,39 +189,51 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { DeviceIdentity protocolLegacy; const bool protocolLegacyParsed = DeviceController::parseIdentityLine( QStringLiteral( - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=0 " + "I (420) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=0 " "device_id=A1B2C3D4E5F6 firmware=0.0.9 capabilities=SMV-4I4V,LIVE-SETPOINTS"), protocolLegacy) && !DeviceController::identitySupportsCurrentContract( protocolLegacy, QStringLiteral(ARSTACK_STUDIO_VERSION)); + DeviceIdentity reducedCapabilities; + const bool capabilityFailClosed = DeviceController::parseIdentityLine( + QStringLiteral( + "I (425) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS"), + reducedCapabilities) && + !DeviceController::identitySupportsCurrentContract( + reducedCapabilities, QStringLiteral(ARSTACK_STUDIO_VERSION)); + DeviceIdentity rejectedIdentity; const bool rejectsWrongTarget = !DeviceController::parseIdentityLine( QStringLiteral( - "ARSTACK identity product=SMV-INJECTOR target=ESP32-S3 protocol=1 " + "I (430) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-S3 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF " "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), rejectedIdentity); const bool rejectsMissingFirmware = !DeviceController::parseIdentityLine( QStringLiteral( - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "I (435) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 boot_id=0123456789ABCDEF " "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), rejectedIdentity); const bool rejectsMalformedBoot = !DeviceController::parseIdentityLine( QStringLiteral( - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "I (440) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=NOT-A-BOOT-ID " "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), rejectedIdentity); if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || - !rejectsWrongTarget || !rejectsMissingFirmware || !rejectsMalformedBoot) { + !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || + !rejectsMalformedBoot) { qCritical().noquote() << "S1 identity contract: FAIL" << "current=" << currentAccepted << "legacy=" << legacyRejectedAsCurrent << "protocol-legacy=" << protocolLegacyParsed + << "capability-fail-closed=" << capabilityFailClosed << "wrong-target=" << rejectsWrongTarget << "missing-firmware=" << rejectsMissingFirmware << "malformed-boot=" << rejectsMalformedBoot; From 3257c1f8935e160519c2edc80a3ece86279b931a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:52:25 +0700 Subject: [PATCH 162/272] feat(studio): add bounded semantic identification state --- apps/arstack_studio/src/DeviceController.hpp | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 01854db63..0d01267e7 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -32,6 +32,8 @@ class DeviceController : public QObject { Q_PROPERTY(QString discoveryStatus READ discoveryStatus NOTIFY discoveryChanged) Q_PROPERTY(bool discovering READ discovering NOTIFY discoveryChanged) Q_PROPERTY(bool deviceVerified READ deviceVerified NOTIFY deviceVerifiedChanged) + Q_PROPERTY(IdentificationState identificationState READ identificationState NOTIFY identificationStateChanged) + Q_PROPERTY(int identifyAttempts READ identifyAttempts NOTIFY identificationStateChanged) Q_PROPERTY(QString deviceProduct READ deviceProduct NOTIFY deviceIdentityChanged) Q_PROPERTY(QString deviceTarget READ deviceTarget NOTIFY deviceIdentityChanged) Q_PROPERTY(QString deviceId READ deviceId NOTIFY deviceIdentityChanged) @@ -62,6 +64,14 @@ class DeviceController : public QObject { Q_PROPERTY(QString ptpTxFailures READ ptpTxFailures NOTIFY ptpStateChanged) public: + enum class IdentificationState { + Idle, + Identifying, + Verified, + Unidentified, + }; + Q_ENUM(IdentificationState) + explicit DeviceController(QObject* parent = nullptr); [[nodiscard]] QStringList ports() const; @@ -69,6 +79,8 @@ class DeviceController : public QObject { [[nodiscard]] QString discoveryStatus() const; [[nodiscard]] bool discovering() const noexcept; [[nodiscard]] bool deviceVerified() const noexcept; + [[nodiscard]] IdentificationState identificationState() const noexcept; + [[nodiscard]] int identifyAttempts() const noexcept; [[nodiscard]] QString deviceProduct() const; [[nodiscard]] QString deviceTarget() const; [[nodiscard]] QString deviceId() const; @@ -108,6 +120,14 @@ class DeviceController : public QObject { const DeviceIdentity& identity, const QString& expectedFirmwareVersion); + // S2 identification policy is deliberately small and deterministic. The + // same predicate drives production retry behavior and the CI regression. + [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } + [[nodiscard]] static constexpr bool identityRetryAllowed(const int attemptsSent) noexcept { + return attemptsSent >= 0 && attemptsSent < identityMaxAttempts(); + } + Q_INVOKABLE void refreshPorts(); Q_INVOKABLE bool autoDetectAndConnect(); Q_INVOKABLE bool connectPort(const QString& portName); @@ -170,6 +190,7 @@ class DeviceController : public QObject { void portsChanged(); void discoveryChanged(); void deviceVerifiedChanged(); + void identificationStateChanged(); void deviceIdentityChanged(); void connectedChanged(); void runningChanged(); @@ -194,10 +215,13 @@ class DeviceController : public QObject { private: bool connectPortInternal(const QString& portName, bool automatic); bool tryNextProbe(); + bool sendIdentifyProbe(); bool sendCommand(const QString& command); void applyIdentity(DeviceIdentity identity); void clearIdentity(); void markDeviceVerified(); + void setIdentificationState(IdentificationState state); + void finishIdentificationTimeout(); void setDiscoveryState(const QString& status, bool active); void setRunning(bool value); void setError(const QString& message); @@ -223,6 +247,9 @@ class DeviceController : public QObject { QString txFailures_{QStringLiteral("—")}; QString signalGeneration_{QStringLiteral("—")}; QString profileGeneration_{QStringLiteral("—")}; + QString lastIdentificationPort_; + IdentificationState identificationState_{IdentificationState::Idle}; + int identifyAttempts_{0}; bool running_{false}; bool discovering_{false}; bool deviceVerified_{false}; From 60444518c17dc2c664a9c821bf2aacabb4f09bfb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:54:13 +0700 Subject: [PATCH 163/272] feat(studio): bound semantic IDENTIFY handshake --- apps/arstack_studio/src/DeviceController.cpp | 109 +++++++++++++++---- 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index ed4ea3949..98d1e9079 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -89,22 +89,17 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { }); verificationTimer_.setSingleShot(true); - verificationTimer_.setInterval(2600); + verificationTimer_.setInterval(identityRetryIntervalMs()); connect(&verificationTimer_, &QTimer::timeout, this, [this] { - if (!serial_.isOpen() || deviceVerified_) return; - const bool wasAutomatic = automaticConnection_; - const bool continueProbing = wasAutomatic && genericProbeActive_ && !probeQueue_.isEmpty(); - disconnectPort(); - if (continueProbing) { - QTimer::singleShot(0, this, [this] { static_cast(tryNextProbe()); }); + if (!serial_.isOpen() || deviceVerified_ || + identificationState_ != IdentificationState::Identifying) { return; } - genericProbeActive_ = false; - setDiscoveryState( - wasAutomatic - ? QStringLiteral("A connected serial device did not identify as an ARStack injector.") - : QStringLiteral("The selected serial port did not answer as an ARStack injector."), - false); + if (identityRetryAllowed(identifyAttempts_) && sendIdentifyProbe()) { + verificationTimer_.start(); + return; + } + finishIdentificationTimeout(); }); refreshPorts(); @@ -115,6 +110,8 @@ QString DeviceController::recommendedPort() const { return recommendedPort_; } QString DeviceController::discoveryStatus() const { return discoveryStatus_; } bool DeviceController::discovering() const noexcept { return discovering_; } bool DeviceController::deviceVerified() const noexcept { return deviceVerified_; } +DeviceController::IdentificationState DeviceController::identificationState() const noexcept { return identificationState_; } +int DeviceController::identifyAttempts() const noexcept { return identifyAttempts_; } QString DeviceController::deviceProduct() const { return identity_.product; } QString DeviceController::deviceTarget() const { return identity_.target; } QString DeviceController::deviceId() const { return identity_.deviceId; } @@ -213,11 +210,23 @@ void DeviceController::refreshPorts() { const bool recommendationChanged = recommended != recommendedPort_; ports_ = std::move(discovered); recommendedPort_ = recommended; + + if (identificationState_ == IdentificationState::Unidentified && + !lastIdentificationPort_.isEmpty() && !ports_.contains(lastIdentificationPort_)) { + lastIdentificationPort_.clear(); + identifyAttempts_ = 0; + setIdentificationState(IdentificationState::Idle); + } + if (portsDidChange) emit portsChanged(); if (recommendationChanged) emit discoveryChanged(); if (!serial_.isOpen() && !discovering_) { - if (!recommendedPort_.isEmpty()) { + if (identificationState_ == IdentificationState::Unidentified) { + setDiscoveryState( + QStringLiteral("ARStack identity was not received. Retry identification or choose firmware setup explicitly."), + false); + } else if (!recommendedPort_.isEmpty()) { setDiscoveryState(QStringLiteral("Compatible ESP32-P4 USB device found."), false); } else if (highConfidence.size() > 1) { setDiscoveryState(QStringLiteral("Multiple Espressif devices found; choose the intended injector."), false); @@ -260,7 +269,8 @@ bool DeviceController::tryNextProbe() { } bool DeviceController::connectPortInternal(const QString& portName, const bool automatic) { - if (portName.trimmed().isEmpty()) { + const QString requestedPort = portName.trimmed(); + if (requestedPort.isEmpty()) { setError(QStringLiteral("Select a serial port first.")); return false; } @@ -272,10 +282,12 @@ bool DeviceController::connectPortInternal(const QString& portName, const bool a } automaticConnection_ = automatic; deviceVerified_ = false; + identifyAttempts_ = 0; + lastIdentificationPort_ = requestedPort; clearIdentity(); setDiscoveryState(QStringLiteral("Verifying ARStack injector identity..."), true); - serial_.setPortName(portName.trimmed()); + serial_.setPortName(requestedPort); serial_.setBaudRate(kBaudRate); serial_.setDataBits(QSerialPort::Data8); serial_.setParity(QSerialPort::NoParity); @@ -284,9 +296,10 @@ bool DeviceController::connectPortInternal(const QString& portName, const bool a if (!serial_.open(QIODevice::ReadWrite)) { automaticConnection_ = false; + setIdentificationState(IdentificationState::Idle); if (!automatic) { setDiscoveryState(QStringLiteral("The selected serial port could not be opened."), false); - setError(QStringLiteral("Cannot open %1: %2").arg(portName, serial_.errorString())); + setError(QStringLiteral("Cannot open %1: %2").arg(requestedPort, serial_.errorString())); } return false; } @@ -298,15 +311,17 @@ bool DeviceController::connectPortInternal(const QString& portName, const bool a profileDeploying_ = false; resetTelemetry(); appendLog(QStringLiteral("•"), QStringLiteral("Connected %1 at 115200 8N1").arg(serial_.portName())); + setIdentificationState(IdentificationState::Identifying); emit connectedChanged(); emit runningChanged(); emit lastErrorChanged(); emit profileStateChanged(); - verificationTimer_.setInterval(genericProbeActive_ ? 1100 : 2600); + + if (!sendIdentifyProbe()) { + finishIdentificationTimeout(); + return false; + } verificationTimer_.start(); - static_cast(sendCommand(QStringLiteral("IDENTIFY"))); - static_cast(sendCommand(QStringLiteral("SHOW"))); - static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); return true; } @@ -330,6 +345,9 @@ void DeviceController::disconnectPort() { clearIdentity(); discovering_ = false; automaticConnection_ = false; + identifyAttempts_ = 0; + lastIdentificationPort_.clear(); + setIdentificationState(IdentificationState::Idle); ptpAvailable_ = false; ptpRunning_ = false; ptpStatus_ = QStringLiteral("Waiting for device"); @@ -537,6 +555,18 @@ void DeviceController::clearLog() { emit logTextChanged(); } +bool DeviceController::sendIdentifyProbe() { + if (!serial_.isOpen() || !identityRetryAllowed(identifyAttempts_)) return false; + ++identifyAttempts_; + emit identificationStateChanged(); + setDiscoveryState( + QStringLiteral("Verifying ARStack semantic identity… attempt %1/%2") + .arg(identifyAttempts_) + .arg(identityMaxAttempts()), + true); + return sendCommand(QStringLiteral("IDENTIFY")); +} + bool DeviceController::sendCommand(const QString& command) { if (!serial_.isOpen()) { setError(QStringLiteral("Device is not connected.")); @@ -557,6 +587,39 @@ void DeviceController::setRunning(const bool value) { emit runningChanged(); } +void DeviceController::setIdentificationState(const IdentificationState state) { + if (identificationState_ == state) return; + identificationState_ = state; + emit identificationStateChanged(); +} + +void DeviceController::finishIdentificationTimeout() { + if (deviceVerified_) return; + + const QString timedOutPort = serial_.portName().trimmed().isEmpty() + ? lastIdentificationPort_ + : serial_.portName(); + const int attempts = identifyAttempts_; + const bool wasAutomatic = automaticConnection_; + const bool continueProbing = wasAutomatic && genericProbeActive_ && !probeQueue_.isEmpty(); + + disconnectPort(); + lastIdentificationPort_ = timedOutPort; + identifyAttempts_ = attempts; + setIdentificationState(IdentificationState::Unidentified); + setDiscoveryState( + QStringLiteral("No ARStack semantic identity received from %1 after %2 bounded attempts.") + .arg(timedOutPort.isEmpty() ? QStringLiteral("the selected port") : timedOutPort) + .arg(attempts), + false); + + if (continueProbing) { + QTimer::singleShot(0, this, [this] { static_cast(tryNextProbe()); }); + return; + } + genericProbeActive_ = false; +} + void DeviceController::setDiscoveryState(const QString& status, const bool active) { if (discoveryStatus_ == status && discovering_ == active) return; discoveryStatus_ = status; @@ -583,9 +646,13 @@ void DeviceController::markDeviceVerified() { automaticConnection_ = false; genericProbeActive_ = false; probeQueue_.clear(); + lastIdentificationPort_ = serial_.portName(); + setIdentificationState(IdentificationState::Verified); setDiscoveryState(QStringLiteral("ARStack ESP32-P4 identity verified."), false); emit deviceVerifiedChanged(); emit deviceMessage(QStringLiteral("ESP32-P4 recognized. Device is ready.")); + static_cast(sendShow()); + static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); static_cast(sendPtpShow()); } From 1a5faa3c0993c876d9e6f2cab5238b315b03c1d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:54:40 +0700 Subject: [PATCH 164/272] refactor(studio): make firmware recovery explicit after identity timeout --- .../src/SmartSessionController.hpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 3f9c392c1..acc7b2a37 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -48,10 +48,10 @@ class SmartSessionController : public QObject { [[nodiscard]] QString expectedFirmwareVersion() const; [[nodiscard]] QString deviceFirmwareVersion() const; - // Recovery selection is intentionally conservative: prefer the device - // classifier's recommended port; otherwise a single visible serial port is - // safe to ROM-probe because FirmwareManager performs a read-only chip check - // and refuses to write until ESP32-P4 pre-v3 is proven and the user approves. + // Recovery is never classified automatically through the ROM bootloader. + // Normal startup first exhausts the bounded semantic IDENTIFY contract. + // Only an explicit user firmware action may then call FirmwareManager's + // read-only ROM target probe before any write is permitted. [[nodiscard]] static QString chooseRecoveryPort( const QString& recommendedPort, const QStringList& visiblePorts); @@ -66,6 +66,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool beginFirmwareInstall(); Q_INVOKABLE bool retryFirmwareUpdate(); Q_INVOKABLE bool retryFirmwareSetup(); + Q_INVOKABLE bool retryIdentification(); signals: void dependenciesChanged(); @@ -79,7 +80,7 @@ class SmartSessionController : public QObject { void reconnectDeviceSignals(); void reconnectProfileSignals(); void reconnectFirmwareSignals(); - void maybeScheduleBlankBoardProbe(); + void refreshRecoveryOfferFromIdentity(); void clearBlankBoardContext(); void latchFirmwareFailure(QString message); bool beginFirmwareOperation(const QString& portName); @@ -95,13 +96,11 @@ class SmartSessionController : public QObject { QTimer discoveryTimer_; QTimer prepareTimer_; QTimer reconnectTimer_; - QTimer blankProbeTimer_; QString state_{QStringLiteral("WAITING FOR DEVICE")}; QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; QString deviceFirmwareVersion_; QString updatePort_; QString blankBoardPort_; - QString blankProbeAttemptedPort_; QString setupErrorStatus_; bool startReady_{false}; bool firmwareUpdateRequired_{false}; @@ -110,9 +109,8 @@ class SmartSessionController : public QObject { bool needsProfileSync_{true}; bool profileSyncInFlight_{false}; bool updateRequested_{false}; - bool blankProbeInFlight_{false}; bool blankBoardDetected_{false}; bool setupError_{false}; int updateReconnectAttempts_{0}; UpdateStage updateStage_{UpdateStage::idle}; -}; \ No newline at end of file +}; From 4659af610b6752b2f56de96385bc989b3b57ca3b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:56:29 +0700 Subject: [PATCH 165/272] feat(studio): stop automatic ROM classification in normal startup --- .../src/SmartSessionController.cpp | 159 ++++++------------ 1 file changed, 55 insertions(+), 104 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index f2ca67ba4..4172887a1 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -32,9 +32,9 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent discoveryTimer_.setInterval(2500); discoveryTimer_.setSingleShot(false); connect(&discoveryTimer_, &QTimer::timeout, this, [this] { - if (!started_ || device_ == nullptr || updateRequested_ || blankProbeInFlight_ || - blankBoardDetected_ || setupError_) return; + if (!started_ || device_ == nullptr || updateRequested_ || blankBoardDetected_ || setupError_) return; if (firmware_ != nullptr && firmware_->busy()) return; + if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) return; if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { static_cast(device_->autoDetectAndConnect()); } @@ -44,38 +44,6 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent prepareTimer_.setSingleShot(true); connect(&prepareTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); - blankProbeTimer_.setInterval(450); - blankProbeTimer_.setSingleShot(true); - connect(&blankProbeTimer_, &QTimer::timeout, this, [this] { - if (!started_ || device_ == nullptr || firmware_ == nullptr || updateRequested_ || - device_->deviceVerified() || device_->discovering() || device_->connected() || - firmware_->busy() || !firmware_->bundleReady() || blankBoardDetected_ || - blankProbeInFlight_ || setupError_) { - return; - } - - const QString port = chooseRecoveryPort(device_->recommendedPort(), device_->ports()); - if (port.isEmpty() || port == blankProbeAttemptedPort_) return; - - blankProbeAttemptedPort_ = port; - blankBoardPort_ = port; - blankProbeInFlight_ = true; - setPresentation( - QStringLiteral("CHECKING DEVICE"), - QStringLiteral("Checking the connected board and firmware state…"), - false, - false); - - if (!firmware_->probeTarget(port)) { - blankProbeInFlight_ = false; - setupError_ = true; - setupErrorStatus_ = firmware_->status().isEmpty() - ? QStringLiteral("Studio could not start the firmware recovery check on %1.").arg(port) - : firmware_->status(); - reconcile(); - } - }); - reconnectTimer_.setInterval(3000); reconnectTimer_.setSingleShot(true); connect(&reconnectTimer_, &QTimer::timeout, this, [this] { @@ -197,8 +165,8 @@ void SmartSessionController::start() { if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { static_cast(device_->autoDetectAndConnect()); } + refreshRecoveryOfferFromIdentity(); reconcile(); - maybeScheduleBlankBoardProbe(); }); } @@ -211,6 +179,7 @@ bool SmartSessionController::beginFirmwareUpdate() { } bool SmartSessionController::beginFirmwareInstall() { + refreshRecoveryOfferFromIdentity(); if (device_ == nullptr || firmware_ == nullptr || !blankBoardDetected_ || !firmware_->bundleReady() || firmware_->busy() || blankBoardPort_.isEmpty()) { return false; @@ -227,8 +196,6 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { updateReconnectAttempts_ = 0; needsProfileSync_ = true; profileSyncInFlight_ = false; - blankProbeTimer_.stop(); - blankProbeInFlight_ = false; setupError_ = false; setupErrorStatus_.clear(); @@ -280,6 +247,18 @@ bool SmartSessionController::retryFirmwareSetup() { return retryFirmwareUpdate(); } +bool SmartSessionController::retryIdentification() { + if (!started_ || device_ == nullptr || updateRequested_ || device_->connected() || + device_->discovering() || (firmware_ != nullptr && firmware_->busy())) { + return false; + } + + clearBlankBoardContext(); + const bool started = device_->autoDetectAndConnect(); + reconcile(); + return started; +} + void SmartSessionController::continueFirmwareUpdate() { if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; if (device_->running()) { @@ -318,30 +297,26 @@ void SmartSessionController::reconnectDeviceSignals() { } reconcile(); }); + connect(device_, &DeviceController::identificationStateChanged, this, [this] { + refreshRecoveryOfferFromIdentity(); + reconcile(); + }); connect(device_, &DeviceController::deviceIdentityChanged, this, [this] { refreshFirmwareIdentity(); emit stateChanged(); reconcile(); }); connect(device_, &DeviceController::connectedChanged, this, [this] { + refreshRecoveryOfferFromIdentity(); reconcile(); - maybeScheduleBlankBoardProbe(); }); connect(device_, &DeviceController::discoveryChanged, this, [this] { + refreshRecoveryOfferFromIdentity(); reconcile(); - maybeScheduleBlankBoardProbe(); }); connect(device_, &DeviceController::portsChanged, this, [this] { - if (device_ == nullptr) return; - if (!blankBoardPort_.isEmpty() && !device_->ports().contains(blankBoardPort_)) { - clearBlankBoardContext(); - } else if (!blankProbeAttemptedPort_.isEmpty() && !device_->ports().contains(blankProbeAttemptedPort_)) { - blankProbeAttemptedPort_.clear(); - setupError_ = false; - setupErrorStatus_.clear(); - } + refreshRecoveryOfferFromIdentity(); reconcile(); - maybeScheduleBlankBoardProbe(); }); connect(device_, &DeviceController::runningChanged, this, [this] { if (updateRequested_ && updateStage_ == UpdateStage::stopping && @@ -378,33 +353,6 @@ void SmartSessionController::reconnectFirmwareSignals() { if (firmware_ == nullptr) return; connect(firmware_, &FirmwareManager::stateChanged, this, [this] { - if (blankProbeInFlight_) { - if (firmware_->busy()) { - reconcile(); - return; - } - - blankProbeInFlight_ = false; - const bool samePort = firmware_->selectedPort() == blankBoardPort_; - if (samePort && firmware_->targetVerified()) { - blankBoardDetected_ = true; - setupError_ = false; - setupErrorStatus_.clear(); - } else if (samePort && firmware_->bootloaderHelpNeeded()) { - blankBoardDetected_ = true; - setupError_ = false; - setupErrorStatus_.clear(); - } else { - blankBoardDetected_ = false; - setupError_ = true; - setupErrorStatus_ = firmware_->status().isEmpty() - ? QStringLiteral("The connected serial device is not a supported ESP32-P4 target.") - : firmware_->status(); - } - reconcile(); - return; - } - if (!updateRequested_) { reconcile(); return; @@ -448,12 +396,6 @@ void SmartSessionController::reconnectFirmwareSignals() { connect(firmware_, &FirmwareManager::operationFailed, this, [this](const QString& message, const bool bootloaderHelpNeeded) { if (!updateRequested_) { - if (blankProbeInFlight_) { - blankProbeInFlight_ = false; - blankBoardDetected_ = bootloaderHelpNeeded; - setupError_ = !bootloaderHelpNeeded; - setupErrorStatus_ = firmware_ != nullptr ? firmware_->status() : QString{}; - } reconcile(); return; } @@ -493,25 +435,34 @@ void SmartSessionController::reconnectFirmwareSignals() { }); } -void SmartSessionController::maybeScheduleBlankBoardProbe() { - if (!started_ || device_ == nullptr || firmware_ == nullptr || updateRequested_ || - blankProbeInFlight_ || blankBoardDetected_ || blankProbeTimer_.isActive() || - setupError_ || device_->deviceVerified() || device_->discovering() || device_->connected() || - firmware_->busy() || !firmware_->bundleReady()) { +void SmartSessionController::refreshRecoveryOfferFromIdentity() { + if (device_ == nullptr || updateRequested_ || setupError_ || device_->deviceVerified() || + device_->identificationState() != DeviceController::IdentificationState::Unidentified) { + if (!updateRequested_ && (device_ == nullptr || + device_->identificationState() != DeviceController::IdentificationState::Unidentified)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + } + return; + } + + // Normal startup stops here: USB descriptor confidence is enough to offer + // recovery, but never enough to classify ROM silicon or authorize a write. + // beginFirmwareInstall() is the explicit boundary that runs probeTarget(). + const QString recommended = device_->recommendedPort().trimmed(); + if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); return; } - const QString port = chooseRecoveryPort(device_->recommendedPort(), device_->ports()); - if (port.isEmpty() || port == blankProbeAttemptedPort_) return; - blankProbeTimer_.start(); + blankBoardPort_ = recommended; + blankBoardDetected_ = true; } void SmartSessionController::clearBlankBoardContext() { - blankProbeTimer_.stop(); - blankProbeInFlight_ = false; blankBoardDetected_ = false; blankBoardPort_.clear(); - blankProbeAttemptedPort_.clear(); setupError_ = false; setupErrorStatus_.clear(); } @@ -521,8 +472,6 @@ void SmartSessionController::latchFirmwareFailure(QString message) { updateRequested_ = false; updateStage_ = UpdateStage::idle; updateReconnectAttempts_ = 0; - blankProbeTimer_.stop(); - blankProbeInFlight_ = false; blankBoardDetected_ = false; setupError_ = true; message = message.trimmed(); @@ -611,19 +560,12 @@ void SmartSessionController::reconcile() { } if (!device_->deviceVerified()) { - if (blankProbeInFlight_) { - setPresentation( - QStringLiteral("CHECKING DEVICE"), - QStringLiteral("Checking the connected board and firmware state…"), - false, - false); - return; - } + refreshRecoveryOfferFromIdentity(); if (blankBoardDetected_) { setPresentation( QStringLiteral("FIRMWARE REQUIRED"), - QStringLiteral("Firmware setup is required on %1. Studio will verify ESP32-P4 before writing anything.") + QStringLiteral("ARStack identity was not received from %1 after bounded retries. If this is the intended ESP32-P4, Studio will verify chip and revision before writing firmware.") .arg(blankBoardPort_), false, false); @@ -641,7 +583,16 @@ void SmartSessionController::reconcile() { return; } - maybeScheduleBlankBoardProbe(); + if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) { + setPresentation( + QStringLiteral("UNIDENTIFIED"), + QStringLiteral("No ARStack semantic identity was received after %1 bounded attempts. Retry identification or select the intended device manually.") + .arg(device_->identifyAttempts()), + false, + false); + return; + } + setPresentation( device_->ports().isEmpty() ? QStringLiteral("WAITING FOR DEVICE") : QStringLiteral("DEVICE FOUND"), device_->ports().isEmpty() From 29906cbbaa21bfea096c78a71f83a173194e633b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 14:57:23 +0700 Subject: [PATCH 166/272] test(studio): lock bounded S2 identity retry policy --- apps/arstack_studio/src/main.cpp | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 4681fce10..5c48addbc 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -39,7 +39,7 @@ class PrimaryWindowCloseFilter final : public QObject { [[nodiscard]] bool closeObserved() const noexcept { return closeObserved_; } protected: - bool eventFilter(QObject* watched, QEvent* event) override { + [[nodiscard]] bool eventFilter(QObject* watched, QEvent* event) override { if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { closeObserved_ = true; app_->quit(); @@ -225,22 +225,39 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), rejectedIdentity); + const bool boundedIdentifyPolicy = + DeviceController::identityMaxAttempts() == 3 && + DeviceController::identityRetryIntervalMs() == 650 && + DeviceController::identityRetryAllowed(0) && + DeviceController::identityRetryAllowed(1) && + DeviceController::identityRetryAllowed(2) && + !DeviceController::identityRetryAllowed(3) && + !DeviceController::identityRetryAllowed(-1); + if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || - !rejectsMalformedBoot) { + !rejectsMalformedBoot || !boundedIdentifyPolicy) { qCritical().noquote() - << "S1 identity contract: FAIL" + << "S1/S2 identity contract: FAIL" << "current=" << currentAccepted << "legacy=" << legacyRejectedAsCurrent << "protocol-legacy=" << protocolLegacyParsed << "capability-fail-closed=" << capabilityFailClosed << "wrong-target=" << rejectsWrongTarget << "missing-firmware=" << rejectsMissingFirmware - << "malformed-boot=" << rejectsMalformedBoot; + << "malformed-boot=" << rejectsMalformedBoot + << "bounded-identify=" << boundedIdentifyPolicy; return 11; } StudioDeviceController device; + const bool startsIdle = + device.identificationState() == DeviceController::IdentificationState::Idle && + device.identifyAttempts() == 0; + if (!startsIdle) { + qCritical().noquote() << "S2 identification state: FAIL · controller did not start Idle"; + return 12; + } if (device.start()) { qCritical().noquote() << "P0 controller policy: FAIL · unverified device was allowed to START"; return 5; @@ -250,7 +267,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { return 6; } qInfo().noquote() - << "P0 controller policy: PASS · typed S1 identity + unverified START/DEPLOY fail closed"; + << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + unverified START/DEPLOY fail closed"; return 0; } } // namespace From 95145f65ed7d3fb143b540b548a2b5c6e8e65cb7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 15:18:39 +0700 Subject: [PATCH 167/272] fix(studio): bound S3 profile synchronization state --- .../src/SmartSessionController.hpp | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index acc7b2a37..d28b4630d 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -20,6 +20,7 @@ class SmartSessionController : public QObject { Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallRequired NOTIFY stateChanged) Q_PROPERTY(bool firmwareRetryAvailable READ firmwareRetryAvailable NOTIFY stateChanged) + Q_PROPERTY(bool profileSyncRetryAvailable READ profileSyncRetryAvailable NOTIFY stateChanged) Q_PROPERTY(bool updatingFirmware READ updatingFirmware NOTIFY stateChanged) Q_PROPERTY(bool updateNeedsBootloaderHelp READ updateNeedsBootloaderHelp NOTIFY stateChanged) Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged) @@ -40,6 +41,7 @@ class SmartSessionController : public QObject { [[nodiscard]] bool firmwareUpdateRequired() const noexcept; [[nodiscard]] bool firmwareInstallRequired() const noexcept; [[nodiscard]] bool firmwareRetryAvailable() const noexcept; + [[nodiscard]] bool profileSyncRetryAvailable() const noexcept; [[nodiscard]] bool updatingFirmware() const noexcept; [[nodiscard]] bool updateNeedsBootloaderHelp() const noexcept; [[nodiscard]] int firmwareProgress() const noexcept; @@ -56,6 +58,19 @@ class SmartSessionController : public QObject { const QString& recommendedPort, const QStringList& visiblePorts); + // S3 profile synchronization is a bounded transaction. A deployment must + // produce a numeric armed generation newer than the baseline when a + // baseline is known. One controlled retry is permitted; after that the + // supervisor enters a terminal, user-retryable PROFILE SYNC ERROR state. + [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 2; } + [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 2500; } + [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { + return attemptsStarted >= 0 && attemptsStarted < profileSyncMaxAttempts(); + } + [[nodiscard]] static bool profileGenerationAdvanced( + const QString& baseline, + const QString& observed) noexcept; + void setDevice(QObject* object); void setProfiles(QObject* object); void setFirmware(QObject* object); @@ -67,6 +82,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool retryFirmwareUpdate(); Q_INVOKABLE bool retryFirmwareSetup(); Q_INVOKABLE bool retryIdentification(); + Q_INVOKABLE bool retryProfileSync(); signals: void dependenciesChanged(); @@ -76,6 +92,7 @@ class SmartSessionController : public QObject { private: enum class UpdateStage { idle, stopping, probing, flashing, reconnecting, waitingForBootloader }; + enum class ProfileSyncStage { idle, deploying, failed }; void reconnectDeviceSignals(); void reconnectProfileSignals(); @@ -89,6 +106,11 @@ class SmartSessionController : public QObject { void refreshFirmwareIdentity(); void continueFirmwareUpdate(); bool firmwareIsCurrent() const; + void resetProfileSync(bool requireSync); + bool beginProfileSync(const QVariantMap& profile); + void handleProfileStateChanged(); + void handleProfileSyncAttemptFailure(QString reason); + void latchProfileSyncFailure(QString reason); DeviceController* device_{nullptr}; SclProfileModel* profiles_{nullptr}; @@ -96,21 +118,26 @@ class SmartSessionController : public QObject { QTimer discoveryTimer_; QTimer prepareTimer_; QTimer reconnectTimer_; + QTimer profileSyncTimer_; QString state_{QStringLiteral("WAITING FOR DEVICE")}; QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; QString deviceFirmwareVersion_; QString updatePort_; QString blankBoardPort_; QString setupErrorStatus_; + QString profileSyncBaselineGeneration_; + QString profileSyncBootId_; + QString profileSyncError_; bool startReady_{false}; bool firmwareUpdateRequired_{false}; bool started_{false}; bool wasReady_{false}; bool needsProfileSync_{true}; - bool profileSyncInFlight_{false}; bool updateRequested_{false}; bool blankBoardDetected_{false}; bool setupError_{false}; int updateReconnectAttempts_{0}; + int profileSyncAttempts_{0}; UpdateStage updateStage_{UpdateStage::idle}; + ProfileSyncStage profileSyncStage_{ProfileSyncStage::idle}; }; From cbf6ef495c06bb575a6c1d980546d339f262e306 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 15:20:13 +0700 Subject: [PATCH 168/272] fix(studio): implement bounded generation-aware S3 profile sync --- .../src/SmartSessionController.cpp | 195 +++++++++++++++--- 1 file changed, 169 insertions(+), 26 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 4172887a1..68e921da0 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -28,6 +28,19 @@ QString SmartSessionController::chooseRecoveryPort( return visiblePorts.front().trimmed(); } +bool SmartSessionController::profileGenerationAdvanced( + const QString& baseline, + const QString& observed) noexcept { + bool observedOk = false; + static_cast(observed.trimmed().toULongLong(&observedOk)); + if (!observedOk) return false; + + bool baselineOk = false; + static_cast(baseline.trimmed().toULongLong(&baselineOk)); + if (!baselineOk) return true; + return observed.trimmed() != baseline.trimmed(); +} + SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { discoveryTimer_.setInterval(2500); discoveryTimer_.setSingleShot(false); @@ -44,6 +57,15 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent prepareTimer_.setSingleShot(true); connect(&prepareTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); + profileSyncTimer_.setInterval(profileSyncTimeoutMs()); + profileSyncTimer_.setSingleShot(true); + connect(&profileSyncTimer_, &QTimer::timeout, this, [this] { + if (profileSyncStage_ != ProfileSyncStage::deploying) return; + handleProfileSyncAttemptFailure(QStringLiteral( + "Timed out waiting for the ESP32-P4 to arm a new profile generation.")); + reconcile(); + }); + reconnectTimer_.setInterval(3000); reconnectTimer_.setSingleShot(true); connect(&reconnectTimer_, &QTimer::timeout, this, [this] { @@ -83,6 +105,10 @@ bool SmartSessionController::firmwareInstallRequired() const noexcept { bool SmartSessionController::firmwareRetryAvailable() const noexcept { return setupError_ && !updatePort_.trimmed().isEmpty() && firmware_ != nullptr && !firmware_->busy(); } +bool SmartSessionController::profileSyncRetryAvailable() const noexcept { + return profileSyncStage_ == ProfileSyncStage::failed && device_ != nullptr && + device_->deviceVerified() && !device_->running(); +} bool SmartSessionController::updatingFirmware() const noexcept { return updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::probing || updateStage_ == UpdateStage::flashing || updateStage_ == UpdateStage::reconnecting; @@ -123,8 +149,8 @@ void SmartSessionController::setDevice(QObject* object) { if (device_ != nullptr) disconnect(device_, nullptr, this, nullptr); device_ = next; deviceFirmwareVersion_.clear(); - needsProfileSync_ = true; - profileSyncInFlight_ = false; + profileSyncBootId_.clear(); + resetProfileSync(true); clearBlankBoardContext(); reconnectDeviceSignals(); emit dependenciesChanged(); @@ -136,8 +162,7 @@ void SmartSessionController::setProfiles(QObject* object) { if (profiles_ == next) return; if (profiles_ != nullptr) disconnect(profiles_, nullptr, this, nullptr); profiles_ = next; - needsProfileSync_ = true; - profileSyncInFlight_ = false; + resetProfileSync(true); reconnectProfileSignals(); emit dependenciesChanged(); reconcile(); @@ -194,8 +219,8 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { updatePort_ = port; updateRequested_ = true; updateReconnectAttempts_ = 0; - needsProfileSync_ = true; - profileSyncInFlight_ = false; + profileSyncBootId_.clear(); + resetProfileSync(true); setupError_ = false; setupErrorStatus_.clear(); @@ -259,6 +284,13 @@ bool SmartSessionController::retryIdentification() { return started; } +bool SmartSessionController::retryProfileSync() { + if (!profileSyncRetryAvailable()) return false; + resetProfileSync(true); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + return true; +} + void SmartSessionController::continueFirmwareUpdate() { if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; if (device_->running()) { @@ -287,13 +319,13 @@ void SmartSessionController::reconnectDeviceSignals() { if (device_ != nullptr && device_->deviceVerified()) { clearBlankBoardContext(); refreshFirmwareIdentity(); - needsProfileSync_ = true; - profileSyncInFlight_ = false; + profileSyncBootId_.clear(); + resetProfileSync(true); prepareTimer_.start(); } else { deviceFirmwareVersion_.clear(); - needsProfileSync_ = true; - profileSyncInFlight_ = false; + profileSyncBootId_.clear(); + resetProfileSync(true); } reconcile(); }); @@ -326,11 +358,7 @@ void SmartSessionController::reconnectDeviceSignals() { reconcile(); }); connect(device_, &DeviceController::profileStateChanged, this, [this] { - if (device_ != nullptr && profileSyncInFlight_ && - device_->profileArmed() && !device_->profileDeploying()) { - profileSyncInFlight_ = false; - needsProfileSync_ = false; - } + handleProfileStateChanged(); reconcile(); }); } @@ -338,13 +366,11 @@ void SmartSessionController::reconnectDeviceSignals() { void SmartSessionController::reconnectProfileSignals() { if (profiles_ == nullptr) return; connect(profiles_, &SclProfileModel::sourceChanged, this, [this] { - needsProfileSync_ = true; - profileSyncInFlight_ = false; + resetProfileSync(true); QTimer::singleShot(0, this, &SmartSessionController::reconcile); }); connect(profiles_, &SclProfileModel::selectedProfileChanged, this, [this] { - needsProfileSync_ = true; - profileSyncInFlight_ = false; + resetProfileSync(true); QTimer::singleShot(0, this, &SmartSessionController::reconcile); }); } @@ -480,6 +506,110 @@ void SmartSessionController::latchFirmwareFailure(QString message) { : std::move(message); } +void SmartSessionController::resetProfileSync(const bool requireSync) { + profileSyncTimer_.stop(); + profileSyncStage_ = ProfileSyncStage::idle; + profileSyncAttempts_ = 0; + profileSyncBaselineGeneration_.clear(); + profileSyncError_.clear(); + needsProfileSync_ = requireSync; +} + +bool SmartSessionController::beginProfileSync(const QVariantMap& profile) { + if (device_ == nullptr || !device_->deviceVerified() || device_->running() || + profileSyncStage_ == ProfileSyncStage::failed || + !profileSyncRetryAllowed(profileSyncAttempts_)) { + return false; + } + + const QString currentBootId = device_->bootId().trimmed(); + if (profileSyncBootId_ == currentBootId && !currentBootId.isEmpty()) { + profileSyncBaselineGeneration_ = device_->profileGeneration().trimmed(); + } else { + // A profile generation only has meaning inside one firmware boot. Do + // not compare a fresh boot against a generation cached from an older + // boot/session; the first armed generation on this boot is authoritative. + profileSyncBaselineGeneration_.clear(); + profileSyncBootId_ = currentBootId; + } + + ++profileSyncAttempts_; + profileSyncStage_ = ProfileSyncStage::deploying; + profileSyncError_.clear(); + + const bool accepted = device_->deployProfile(profile); + if (!accepted) { + if (profileSyncStage_ == ProfileSyncStage::deploying) { + handleProfileSyncAttemptFailure( + device_->lastError().isEmpty() + ? QStringLiteral("Studio could not send the profile transaction to the ESP32-P4.") + : device_->lastError()); + } + return false; + } + + if (profileSyncStage_ == ProfileSyncStage::deploying) profileSyncTimer_.start(); + return true; +} + +void SmartSessionController::handleProfileStateChanged() { + if (device_ == nullptr || profileSyncStage_ != ProfileSyncStage::deploying) return; + if (device_->profileDeploying()) return; + + if (device_->profileArmed()) { + const QString observedGeneration = device_->profileGeneration().trimmed(); + if (!profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) { + // Ignore an out-of-order/stale arm indication. The bounded timer + // remains authoritative and will retry/fail if no new generation + // arrives for this transaction. + return; + } + + profileSyncTimer_.stop(); + profileSyncStage_ = ProfileSyncStage::idle; + profileSyncAttempts_ = 0; + profileSyncBaselineGeneration_ = observedGeneration; + profileSyncBootId_ = device_->bootId().trimmed(); + profileSyncError_.clear(); + needsProfileSync_ = false; + return; + } + + handleProfileSyncAttemptFailure( + device_->lastError().isEmpty() + ? QStringLiteral("The ESP32-P4 rejected the profile transaction.") + : device_->lastError()); +} + +void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { + profileSyncTimer_.stop(); + if (profileSyncStage_ == ProfileSyncStage::failed) return; + + reason = reason.trimmed(); + if (reason.isEmpty()) reason = QStringLiteral("Profile synchronization did not complete."); + + if (profileSyncRetryAllowed(profileSyncAttempts_)) { + profileSyncStage_ = ProfileSyncStage::idle; + profileSyncError_ = std::move(reason); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + return; + } + + latchProfileSyncFailure(std::move(reason)); +} + +void SmartSessionController::latchProfileSyncFailure(QString reason) { + profileSyncTimer_.stop(); + profileSyncStage_ = ProfileSyncStage::failed; + needsProfileSync_ = true; + reason = reason.trimmed(); + if (reason.isEmpty()) reason = QStringLiteral("Profile synchronization did not complete."); + profileSyncError_ = QStringLiteral( + "%1 Studio stopped after %2 bounded attempts; Start remains inhibited. Retry profile synchronization or reconnect the device.") + .arg(reason) + .arg(profileSyncMaxAttempts()); +} + bool SmartSessionController::ensureDefaultProfile() { if (profiles_ == nullptr) return false; if (profiles_->hasProfiles()) return true; @@ -656,10 +786,26 @@ void SmartSessionController::reconcile() { return; } - if (device_->profileDeploying() || prepareTimer_.isActive()) { + if (profileSyncStage_ == ProfileSyncStage::failed) { + setPresentation( + QStringLiteral("PROFILE SYNC ERROR"), + profileSyncError_.isEmpty() + ? QStringLiteral("Profile synchronization failed after bounded retries. Retry profile synchronization or reconnect the device.") + : profileSyncError_, + false, + false); + return; + } + + if (profileSyncStage_ == ProfileSyncStage::deploying || + device_->profileDeploying() || prepareTimer_.isActive()) { setPresentation( QStringLiteral("PREPARING 4I+4V"), - QStringLiteral("Preparing the default 4I+4V injection profile…"), + profileSyncStage_ == ProfileSyncStage::deploying + ? QStringLiteral("Synchronizing the default 4I+4V profile · attempt %1/%2…") + .arg(profileSyncAttempts_) + .arg(profileSyncMaxAttempts()) + : QStringLiteral("Preparing the default 4I+4V injection profile…"), false, false); return; @@ -671,15 +817,12 @@ void SmartSessionController::reconcile() { QStringLiteral("Synchronizing the default 4I+4V profile…"), false, false); - if (!profileSyncInFlight_) { - profileSyncInFlight_ = device_->deployProfile(profile); - } + static_cast(beginProfileSync(profile)); return; } if (!device_->profileArmed()) { - needsProfileSync_ = true; - profileSyncInFlight_ = false; + resetProfileSync(true); setPresentation( QStringLiteral("PREPARING 4I+4V"), QStringLiteral("Restoring the default 4I+4V profile…"), From c45bba250823e1d4de1a98953b12eb8ba5ca3878 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 15:21:35 +0700 Subject: [PATCH 169/272] fix(studio): include profile transaction value type --- apps/arstack_studio/src/SmartSessionController.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index d28b4630d..d7c187c9b 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -4,6 +4,7 @@ #include #include #include +#include class DeviceController; class FirmwareManager; From d4b9df47f635835e89a61eb19855f6fbc5b3a5c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 15:22:38 +0700 Subject: [PATCH 170/272] test(studio): lock bounded S3 profile sync policy --- apps/arstack_studio/src/main.cpp | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 5c48addbc..e141b2b6c 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -234,11 +234,27 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { !DeviceController::identityRetryAllowed(3) && !DeviceController::identityRetryAllowed(-1); + const bool boundedProfileSyncPolicy = + SmartSessionController::profileSyncMaxAttempts() == 2 && + SmartSessionController::profileSyncTimeoutMs() == 2500 && + SmartSessionController::profileSyncRetryAllowed(0) && + SmartSessionController::profileSyncRetryAllowed(1) && + !SmartSessionController::profileSyncRetryAllowed(2) && + !SmartSessionController::profileSyncRetryAllowed(-1) && + SmartSessionController::profileGenerationAdvanced( + QStringLiteral("1"), QStringLiteral("2")) && + SmartSessionController::profileGenerationAdvanced( + QStringLiteral("—"), QStringLiteral("2")) && + !SmartSessionController::profileGenerationAdvanced( + QStringLiteral("2"), QStringLiteral("2")) && + !SmartSessionController::profileGenerationAdvanced( + QStringLiteral("2"), QStringLiteral("not-a-generation")); + if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || - !rejectsMalformedBoot || !boundedIdentifyPolicy) { + !rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy) { qCritical().noquote() - << "S1/S2 identity contract: FAIL" + << "S1/S2/S3 control-plane contract: FAIL" << "current=" << currentAccepted << "legacy=" << legacyRejectedAsCurrent << "protocol-legacy=" << protocolLegacyParsed @@ -246,7 +262,8 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { << "wrong-target=" << rejectsWrongTarget << "missing-firmware=" << rejectsMissingFirmware << "malformed-boot=" << rejectsMalformedBoot - << "bounded-identify=" << boundedIdentifyPolicy; + << "bounded-identify=" << boundedIdentifyPolicy + << "bounded-profile-sync=" << boundedProfileSyncPolicy; return 11; } @@ -267,7 +284,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { return 6; } qInfo().noquote() - << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + unverified START/DEPLOY fail closed"; + << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded generation-aware profile sync + unverified START/DEPLOY fail closed"; return 0; } } // namespace From c7ec55897e555a415fbaf1d263aa033990054bbe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 15:23:46 +0700 Subject: [PATCH 171/272] fix(studio): surface actionable S3 profile sync failure --- apps/arstack_studio/qml/WorkflowBar.qml | 27 ++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index ab8f6d219..548c609ee 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -164,6 +164,7 @@ SurfacePanel { if (smartSession.state === "READY") return "Ready to inject" if (smartSession.state === "RUNNING") return "Injection running" if (smartSession.state === "PROFILE BLOCKED") return "Profile unavailable" + if (smartSession.state === "PROFILE SYNC ERROR") return "Profile sync failed" if (smartSession.state === "SETUP ERROR") return "Setup issue" return smartSession.state } @@ -175,7 +176,8 @@ SurfacePanel { smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || smartSession.state === "UPDATE NEEDS BOOT") return theme.amber if (smartSession.state === "DEVICE FOUND") return theme.accent - if (smartSession.state === "PROFILE BLOCKED" || smartSession.state === "SETUP ERROR") return theme.red + if (smartSession.state === "PROFILE BLOCKED" || smartSession.state === "PROFILE SYNC ERROR" || + smartSession.state === "SETUP ERROR") return theme.red return theme.muted } @@ -187,6 +189,7 @@ SurfacePanel { if (smartSession.updateNeedsBootloaderHelp) return "Complete Download mode recovery before starting injection." if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE") return "Studio is identifying the device automatically." if (smartSession.state === "PREPARING 4I+4V") return "Studio is preparing the default 4I+4V injection automatically." + if (smartSession.state === "PROFILE SYNC ERROR") return smartSession.statusText if (smartSession.state === "WAITING FOR DEVICE") return "Connect ESP32-P4; Studio will detect it automatically." return smartSession.statusText } @@ -207,6 +210,13 @@ SurfacePanel { controller.showMessage(ribbon.startReason(), false) return } + if (smartSession.profileSyncRetryAvailable) { + if (smartSession.retryProfileSync()) + controller.showMessage("Retrying 4I+4V profile synchronization.", false) + else + controller.showMessage(smartSession.statusText, true) + return + } if (!smartSession.startReady) { controller.showMessage(ribbon.startReason(), false) return @@ -660,6 +670,21 @@ SurfacePanel { implicitWidth: 128 onClicked: { ribbon.installPromptDeferred = false; installDialog.open() } } + CalmButton { + visible: smartSession.profileSyncRetryAvailable + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Retry profile" + tone: "accent" + implicitHeight: 32 + implicitWidth: 112 + onClicked: { + if (smartSession.retryProfileSync()) + controller.showMessage("Retrying 4I+4V profile synchronization.", false) + else + controller.showMessage(smartSession.statusText, true) + } + } RunButton { text: "Start" From 8daf7ad9aea06459421d1f75ceb17e6b1c1402d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 15:25:41 +0700 Subject: [PATCH 172/272] fix(studio): allow supervisor to expire stale profile deployment --- apps/arstack_studio/src/DeviceController.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 0d01267e7..5f2f3e792 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -148,6 +148,17 @@ class DeviceController : public QObject { Q_INVOKABLE bool setQuality(const QString& signalId, quint32 quality); Q_INVOKABLE bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); Q_INVOKABLE virtual bool deployProfile(const QVariantMap& profile); + + // A bounded supervisor timeout must be able to retire stale host-side + // deployment state even when the serial peer sends no terminal response. + // The next PROFILE BEGIN safely replaces any firmware staging transaction. + void abandonProfileDeployment() { + if (!profileDeploying_) return; + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + } + Q_INVOKABLE bool sendPtpShow(); Q_INVOKABLE bool startPtp(); Q_INVOKABLE bool stopPtp(); From 7e78106e5766c85d85b0a7d38135b943dadebe82 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 15:27:54 +0700 Subject: [PATCH 173/272] fix(studio): expire silent profile transaction before bounded retry --- .../arstack_studio/src/SmartSessionController.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 68e921da0..3561559b7 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -32,13 +32,13 @@ bool SmartSessionController::profileGenerationAdvanced( const QString& baseline, const QString& observed) noexcept { bool observedOk = false; - static_cast(observed.trimmed().toULongLong(&observedOk)); + const qulonglong observedGeneration = observed.trimmed().toULongLong(&observedOk); if (!observedOk) return false; bool baselineOk = false; - static_cast(baseline.trimmed().toULongLong(&baselineOk)); + const qulonglong baselineGeneration = baseline.trimmed().toULongLong(&baselineOk); if (!baselineOk) return true; - return observed.trimmed() != baseline.trimmed(); + return observedGeneration != baselineGeneration; } SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { @@ -589,13 +589,18 @@ void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { if (reason.isEmpty()) reason = QStringLiteral("Profile synchronization did not complete."); if (profileSyncRetryAllowed(profileSyncAttempts_)) { + // Retire DeviceController's pending marker before retrying. Otherwise a + // silent peer can leave profileDeploying=true forever even though the + // supervisor transaction already timed out. profileSyncStage_ = ProfileSyncStage::idle; - profileSyncError_ = std::move(reason); + profileSyncError_ = reason; + if (device_ != nullptr && device_->profileDeploying()) device_->abandonProfileDeployment(); QTimer::singleShot(0, this, &SmartSessionController::reconcile); return; } - latchProfileSyncFailure(std::move(reason)); + latchProfileSyncFailure(reason); + if (device_ != nullptr && device_->profileDeploying()) device_->abandonProfileDeployment(); } void SmartSessionController::latchProfileSyncFailure(QString reason) { From 9b8ec1a3f906b0dfa07d64a99e7b932447bcf94e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:19:18 +0700 Subject: [PATCH 174/272] refactor(studio): add long-lived DeviceIoWorker transport owner --- apps/arstack_studio/src/DeviceIoWorker.hpp | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/arstack_studio/src/DeviceIoWorker.hpp diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp new file mode 100644 index 000000000..fb233320f --- /dev/null +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +class QSerialPort; +class QTimer; + +// Long-lived serial transport owner for ARStack Studio. +// +// S4 invariant: QSerialPort and every timer that directly drives serial I/O are +// created only after this object has moved to its worker thread. The GUI-facing +// DeviceController facade never opens, closes, reads, or writes a serial handle. +class DeviceIoWorker final : public QObject { + Q_OBJECT + +public: + explicit DeviceIoWorker(QObject* parent = nullptr); + + [[nodiscard]] static constexpr int commandQueueCapacity() noexcept { return 64; } + [[nodiscard]] static constexpr int presencePollIntervalMs() noexcept { return 750; } + [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } + [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } + +public slots: + void initialize(); + void shutdown(bool requestStop); + void refreshPorts(); + void autoDetectAndConnect(); + void connectPort(const QString& portName); + void disconnectPort(); + void confirmIdentity(); + void enqueueCommands(const QStringList& commands, bool quiet); + void setHeartbeatEnabled(bool enabled); + +signals: + void ready(bool serialAffinityValid); + void portsObserved(const QStringList& ports, const QString& recommendedPort, int highConfidenceCount); + void openingPort(const QString& portName, bool automatic); + void portOpened(const QString& portName, bool automatic); + void portOpenFailed(const QString& portName, const QString& message, bool automatic); + void portClosed(const QString& portName); + void portReleased(const QString& portName); + void identificationAttempt(const QString& portName, int attempt, int maximum); + void identificationTimedOut(const QString& portName, int attempts, bool continuingProbe); + void automaticProbeExhausted(); + void lineReceived(const QString& line); + void commandTransmitted(const QString& command, bool quiet); + void commandRejected(const QString& message); + void transportError(const QString& message, bool fatal); + +private: + struct PendingCommand final { + QString text; + QByteArray bytes; + bool quiet{false}; + }; + + void refreshPortsInternal(bool forceSignal); + void tryNextProbe(); + bool openPortInternal(const QString& portName, bool automatic); + void closePortInternal(bool emitRelease); + bool sendIdentifyProbe(); + void finishIdentificationTimeout(); + bool enqueueCommandsInternal(const QStringList& commands, bool quiet, bool reportRejection); + void pumpWriteQueue(); + void completeActiveWrite(); + void processReadyRead(); + void handleSerialFailure(const QString& message); + + QSerialPort* serial_{nullptr}; + QTimer* verificationTimer_{nullptr}; + QTimer* presenceTimer_{nullptr}; + QTimer* heartbeatTimer_{nullptr}; + + QStringList ports_; + QString recommendedPort_; + QStringList probeQueue_; + QByteArray pendingRx_; + QQueue commandQueue_; + PendingCommand activeCommand_; + QString lastPort_; + + int highConfidenceCount_{0}; + int identifyAttempts_{0}; + bool initialized_{false}; + bool shuttingDown_{false}; + bool automaticConnection_{false}; + bool genericProbeActive_{false}; + bool identityConfirmed_{false}; + bool writeActive_{false}; + bool heartbeatEnabled_{false}; +}; From a0a3406f6f96317a6644949fcc28c1f6169ffa50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:20:21 +0700 Subject: [PATCH 175/272] refactor(studio): implement threaded serial IO worker --- apps/arstack_studio/src/DeviceIoWorker.cpp | 438 +++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 apps/arstack_studio/src/DeviceIoWorker.cpp diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp new file mode 100644 index 000000000..a42c4bf86 --- /dev/null +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeviceIoWorker.hpp" + +#include "DeviceController.hpp" + +#include +#include +#include +#include + +#include + +namespace { +constexpr qint32 kBaudRate = 115200; +constexpr qsizetype kMaxRxBytesWithoutLine = 16384; +constexpr qsizetype kMaxCommandBytes = 512; + +struct PortCandidate final { + QString name; + int confidence{}; +}; + +[[nodiscard]] int portConfidence(const QSerialPortInfo& info) { + int score = 0; + if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100; + const QString identity = QStringLiteral("%1 %2 %3") + .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower(); + if (identity.contains(QStringLiteral("esp32-p4"))) score += 90; + else if (identity.contains(QStringLiteral("esp32"))) score += 65; + if (identity.contains(QStringLiteral("espressif"))) score += 60; + if (identity.contains(QStringLiteral("usb jtag")) || + identity.contains(QStringLiteral("usb serial"))) score += 15; + return score; +} +} // namespace + +DeviceIoWorker::DeviceIoWorker(QObject* parent) : QObject(parent) {} + +void DeviceIoWorker::initialize() { + if (initialized_) return; + initialized_ = true; + shuttingDown_ = false; + + // S4 construction rule: these objects are deliberately created here, + // after DeviceIoWorker has moved to its dedicated thread. + serial_ = new QSerialPort(this); + verificationTimer_ = new QTimer(this); + presenceTimer_ = new QTimer(this); + heartbeatTimer_ = new QTimer(this); + + verificationTimer_->setSingleShot(true); + verificationTimer_->setInterval(identityRetryIntervalMs()); + connect(verificationTimer_, &QTimer::timeout, this, [this] { + if (serial_ == nullptr || !serial_->isOpen() || identityConfirmed_) return; + if (identifyAttempts_ < identityMaxAttempts() && sendIdentifyProbe()) { + verificationTimer_->start(); + return; + } + finishIdentificationTimeout(); + }); + + presenceTimer_->setSingleShot(false); + presenceTimer_->setInterval(presencePollIntervalMs()); + connect(presenceTimer_, &QTimer::timeout, this, [this] { + refreshPortsInternal(false); + }); + + heartbeatTimer_->setSingleShot(false); + heartbeatTimer_->setInterval(heartbeatIntervalMs()); + connect(heartbeatTimer_, &QTimer::timeout, this, [this] { + if (!heartbeatEnabled_ || serial_ == nullptr || !serial_->isOpen() || !identityConfirmed_) { + heartbeatTimer_->stop(); + return; + } + static_cast(enqueueCommandsInternal( + {QStringLiteral("HEARTBEAT")}, true, true)); + }); + + connect(serial_, &QSerialPort::readyRead, this, &DeviceIoWorker::processReadyRead); + connect(serial_, &QSerialPort::bytesWritten, this, [this](qint64) { + if (serial_ != nullptr && writeActive_ && serial_->bytesToWrite() == 0) { + completeActiveWrite(); + } + }); + connect(serial_, &QSerialPort::errorOccurred, this, [this](const QSerialPort::SerialPortError error) { + if (error == QSerialPort::NoError || serial_ == nullptr || !serial_->isOpen()) return; + const QString message = serial_->errorString(); + switch (error) { + case QSerialPort::ResourceError: + case QSerialPort::DeviceNotFoundError: + case QSerialPort::PermissionError: + case QSerialPort::ReadError: + case QSerialPort::WriteError: + handleSerialFailure(message); + return; + default: + emit transportError(message, false); + return; + } + }); + + presenceTimer_->start(); + refreshPortsInternal(true); + emit ready(serial_->thread() == QThread::currentThread() && thread() == QThread::currentThread()); +} + +void DeviceIoWorker::shutdown(const bool requestStop) { + if (!initialized_ || shuttingDown_) return; + shuttingDown_ = true; + + if (verificationTimer_ != nullptr) verificationTimer_->stop(); + if (presenceTimer_ != nullptr) presenceTimer_->stop(); + if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + heartbeatEnabled_ = false; + probeQueue_.clear(); + genericProbeActive_ = false; + + // Graceful application shutdown is the only place this worker performs a + // short bounded wait. Normal UI/device operations never block the GUI. + if (requestStop && serial_ != nullptr && serial_->isOpen()) { + const QByteArray stopBytes{"STOP\n"}; + if (serial_->write(stopBytes) == stopBytes.size()) { + static_cast(serial_->waitForBytesWritten(120)); + } + } + + commandQueue_.clear(); + writeActive_ = false; + activeCommand_ = {}; + closePortInternal(true); +} + +void DeviceIoWorker::refreshPorts() { + if (!initialized_ || shuttingDown_) return; + refreshPortsInternal(true); +} + +void DeviceIoWorker::refreshPortsInternal(const bool forceSignal) { + QList candidates; + for (const auto& info : QSerialPortInfo::availablePorts()) { + candidates.push_back({info.portName(), portConfidence(info)}); + } + std::sort(candidates.begin(), candidates.end(), [](const PortCandidate& left, const PortCandidate& right) { + if (left.confidence != right.confidence) return left.confidence > right.confidence; + return left.name.localeAwareCompare(right.name) < 0; + }); + + QStringList discovered; + QStringList highConfidence; + for (const auto& candidate : candidates) { + discovered.push_back(candidate.name); + if (candidate.confidence >= 60) highConfidence.push_back(candidate.name); + } + discovered.removeDuplicates(); + const QString recommended = highConfidence.size() == 1 ? highConfidence.front() : QString{}; + const bool changed = discovered != ports_ || recommended != recommendedPort_ || + highConfidence.size() != highConfidenceCount_; + + ports_ = std::move(discovered); + recommendedPort_ = recommended; + highConfidenceCount_ = highConfidence.size(); + if (forceSignal || changed) { + emit portsObserved(ports_, recommendedPort_, highConfidenceCount_); + } +} + +void DeviceIoWorker::autoDetectAndConnect() { + if (!initialized_ || shuttingDown_) return; + if (serial_ != nullptr && serial_->isOpen()) return; + + refreshPortsInternal(true); + probeQueue_.clear(); + genericProbeActive_ = false; + + if (!recommendedPort_.isEmpty()) { + if (!openPortInternal(recommendedPort_, true)) emit automaticProbeExhausted(); + return; + } + if (ports_.isEmpty()) { + emit automaticProbeExhausted(); + return; + } + + probeQueue_ = ports_; + genericProbeActive_ = true; + tryNextProbe(); +} + +void DeviceIoWorker::connectPort(const QString& portName) { + if (!initialized_ || shuttingDown_) return; + probeQueue_.clear(); + genericProbeActive_ = false; + const QString requested = portName.trimmed(); + if (requested.isEmpty()) { + emit portOpenFailed({}, QStringLiteral("Select a serial port first."), false); + return; + } + static_cast(openPortInternal(requested, false)); +} + +void DeviceIoWorker::tryNextProbe() { + if (shuttingDown_) return; + while (!probeQueue_.isEmpty()) { + const QString next = probeQueue_.takeFirst(); + if (openPortInternal(next, true)) return; + } + genericProbeActive_ = false; + emit automaticProbeExhausted(); +} + +bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automatic) { + if (serial_ == nullptr || portName.trimmed().isEmpty()) return false; + if (serial_->isOpen()) closePortInternal(true); + + automaticConnection_ = automatic; + identityConfirmed_ = false; + identifyAttempts_ = 0; + pendingRx_.clear(); + commandQueue_.clear(); + writeActive_ = false; + activeCommand_ = {}; + lastPort_ = portName.trimmed(); + + emit openingPort(lastPort_, automatic); + + serial_->setPortName(lastPort_); + serial_->setBaudRate(kBaudRate); + serial_->setDataBits(QSerialPort::Data8); + serial_->setParity(QSerialPort::NoParity); + serial_->setStopBits(QSerialPort::OneStop); + serial_->setFlowControl(QSerialPort::NoFlowControl); + + if (!serial_->open(QIODevice::ReadWrite)) { + const QString message = QStringLiteral("Cannot open %1: %2").arg(lastPort_, serial_->errorString()); + emit portOpenFailed(lastPort_, message, automatic); + automaticConnection_ = false; + return false; + } + + emit portOpened(lastPort_, automatic); + if (!sendIdentifyProbe()) { + finishIdentificationTimeout(); + return false; + } + verificationTimer_->start(); + return true; +} + +void DeviceIoWorker::disconnectPort() { + if (!initialized_) return; + probeQueue_.clear(); + genericProbeActive_ = false; + automaticConnection_ = false; + closePortInternal(true); +} + +void DeviceIoWorker::closePortInternal(const bool emitRelease) { + if (serial_ == nullptr) return; + if (verificationTimer_ != nullptr) verificationTimer_->stop(); + if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + heartbeatEnabled_ = false; + identityConfirmed_ = false; + identifyAttempts_ = 0; + pendingRx_.clear(); + commandQueue_.clear(); + writeActive_ = false; + activeCommand_ = {}; + + const bool wasOpen = serial_->isOpen(); + const QString releasedPort = serial_->portName().trimmed().isEmpty() ? lastPort_ : serial_->portName(); + if (wasOpen) serial_->close(); + if (wasOpen) emit portClosed(releasedPort); + if (emitRelease && wasOpen) emit portReleased(releasedPort); +} + +bool DeviceIoWorker::sendIdentifyProbe() { + if (serial_ == nullptr || !serial_->isOpen() || identityConfirmed_ || + identifyAttempts_ >= identityMaxAttempts()) { + return false; + } + + ++identifyAttempts_; + emit identificationAttempt(serial_->portName(), identifyAttempts_, identityMaxAttempts()); + return enqueueCommandsInternal({QStringLiteral("IDENTIFY")}, false, true); +} + +void DeviceIoWorker::finishIdentificationTimeout() { + if (serial_ == nullptr || identityConfirmed_) return; + + const QString timedOutPort = serial_->portName().trimmed().isEmpty() + ? lastPort_ + : serial_->portName(); + const int attempts = identifyAttempts_; + const bool continueProbing = automaticConnection_ && genericProbeActive_ && !probeQueue_.isEmpty(); + + closePortInternal(true); + emit identificationTimedOut(timedOutPort, attempts, continueProbing); + + if (continueProbing) { + QTimer::singleShot(0, this, &DeviceIoWorker::tryNextProbe); + return; + } + genericProbeActive_ = false; + automaticConnection_ = false; +} + +void DeviceIoWorker::confirmIdentity() { + if (serial_ == nullptr || !serial_->isOpen()) return; + identityConfirmed_ = true; + automaticConnection_ = false; + genericProbeActive_ = false; + probeQueue_.clear(); + if (verificationTimer_ != nullptr) verificationTimer_->stop(); +} + +void DeviceIoWorker::enqueueCommands(const QStringList& commands, const bool quiet) { + static_cast(enqueueCommandsInternal(commands, quiet, true)); +} + +bool DeviceIoWorker::enqueueCommandsInternal( + const QStringList& commands, + const bool quiet, + const bool reportRejection) { + if (serial_ == nullptr || !serial_->isOpen()) { + if (reportRejection) emit commandRejected(QStringLiteral("Device is not connected.")); + return false; + } + if (commands.isEmpty()) return true; + + const int outstanding = commandQueue_.size() + (writeActive_ ? 1 : 0); + if (commands.size() > commandQueueCapacity() - outstanding) { + if (reportRejection) { + emit commandRejected(QStringLiteral( + "Device command queue is full; command batch was rejected without partial enqueue.")); + } + return false; + } + + QList prepared; + prepared.reserve(commands.size()); + for (const QString& command : commands) { + const QString trimmed = command.trimmed(); + if (trimmed.isEmpty() || trimmed.contains(QLatin1Char('\n')) || trimmed.contains(QLatin1Char('\r'))) { + if (reportRejection) emit commandRejected(QStringLiteral("Invalid device command.")); + return false; + } + const QByteArray bytes = trimmed.toUtf8() + '\n'; + if (bytes.size() > kMaxCommandBytes) { + if (reportRejection) emit commandRejected(QStringLiteral("Device command exceeds the bounded transport size.")); + return false; + } + prepared.push_back(PendingCommand{trimmed, bytes, quiet}); + } + + for (auto& command : prepared) commandQueue_.enqueue(std::move(command)); + pumpWriteQueue(); + return true; +} + +void DeviceIoWorker::pumpWriteQueue() { + if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || commandQueue_.isEmpty()) return; + + activeCommand_ = commandQueue_.dequeue(); + const qint64 accepted = serial_->write(activeCommand_.bytes); + if (accepted != activeCommand_.bytes.size()) { + const QString detail = accepted < 0 + ? serial_->errorString() + : QStringLiteral("Serial write accepted only %1/%2 bytes.") + .arg(accepted) + .arg(activeCommand_.bytes.size()); + writeActive_ = false; + activeCommand_ = {}; + handleSerialFailure(detail); + return; + } + writeActive_ = true; + if (serial_->bytesToWrite() == 0) completeActiveWrite(); +} + +void DeviceIoWorker::completeActiveWrite() { + if (!writeActive_) return; + const PendingCommand completed = activeCommand_; + writeActive_ = false; + activeCommand_ = {}; + emit commandTransmitted(completed.text, completed.quiet); + pumpWriteQueue(); +} + +void DeviceIoWorker::processReadyRead() { + if (serial_ == nullptr || !serial_->isOpen()) return; + pendingRx_.append(serial_->readAll()); + if (pendingRx_.size() > kMaxRxBytesWithoutLine && pendingRx_.indexOf('\n') < 0) { + handleSerialFailure(QStringLiteral("Serial receive line exceeded the bounded input size.")); + return; + } + + while (serial_ != nullptr && serial_->isOpen()) { + const qsizetype newline = pendingRx_.indexOf('\n'); + if (newline < 0) break; + QByteArray raw = pendingRx_.left(newline); + pendingRx_.remove(0, newline + 1); + if (!raw.isEmpty() && raw.endsWith('\r')) raw.chop(1); + const QString line = QString::fromUtf8(raw); + + // Use the one existing semantic parser to stop the handshake timer in + // the transport thread before a GUI-thread callback can race its expiry. + DeviceIdentity identity; + if (!identityConfirmed_ && DeviceController::parseIdentityLine(line, identity)) { + confirmIdentity(); + } + emit lineReceived(line); + } +} + +void DeviceIoWorker::handleSerialFailure(const QString& message) { + const QString detail = message.trimmed().isEmpty() + ? QStringLiteral("Serial transport failed.") + : message.trimmed(); + emit transportError(detail, true); + probeQueue_.clear(); + genericProbeActive_ = false; + automaticConnection_ = false; + closePortInternal(true); +} + +void DeviceIoWorker::setHeartbeatEnabled(const bool enabled) { + heartbeatEnabled_ = enabled; + if (heartbeatTimer_ == nullptr) return; + + if (!enabled || serial_ == nullptr || !serial_->isOpen() || !identityConfirmed_) { + heartbeatTimer_->stop(); + return; + } + + static_cast(enqueueCommandsInternal({QStringLiteral("HEARTBEAT")}, true, true)); + if (!heartbeatTimer_->isActive()) heartbeatTimer_->start(); +} From 3c39d86fa9f7505b96d2dcacc781c4c07764a106 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:21:26 +0700 Subject: [PATCH 176/272] refactor(studio): turn DeviceController into worker-backed facade --- apps/arstack_studio/src/DeviceController.hpp | 54 +++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 5f2f3e792..b4f126362 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -2,11 +2,12 @@ #pragma once #include -#include #include -#include +#include #include +class DeviceIoWorker; + struct DeviceIdentity final { QString product; QString target; @@ -73,6 +74,7 @@ class DeviceController : public QObject { Q_ENUM(IdentificationState) explicit DeviceController(QObject* parent = nullptr); + ~DeviceController() override; [[nodiscard]] QStringList ports() const; [[nodiscard]] QString recommendedPort() const; @@ -111,6 +113,12 @@ class DeviceController : public QObject { [[nodiscard]] QString ptpSyncSent() const; [[nodiscard]] QString ptpTxFailures() const; + // S4 worker-boundary diagnostics used by the native regression gate. + [[nodiscard]] bool ioWorkerReady() const noexcept { return ioWorkerReady_; } + [[nodiscard]] bool ioWorkerAffinityValid() const noexcept { return ioWorkerAffinityValid_; } + [[nodiscard]] static constexpr int ioCommandQueueCapacity() noexcept { return 64; } + [[nodiscard]] static constexpr int ioPresencePollIntervalMs() noexcept { return 750; } + // S1 semantic identity contract. The parser accepts the immediately // preceding ARStack v1 identity grammar without boot_id so an installed // pre-S1 image is recognized as legacy/updateable rather than "blank". @@ -120,8 +128,9 @@ class DeviceController : public QObject { const DeviceIdentity& identity, const QString& expectedFirmwareVersion); - // S2 identification policy is deliberately small and deterministic. The - // same predicate drives production retry behavior and the CI regression. + // S2 identification policy remains the public deterministic contract. S4 + // moves its timer/transport execution into DeviceIoWorker without changing + // the attempt count or cadence. [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } [[nodiscard]] static constexpr bool identityRetryAllowed(const int attemptsSent) noexcept { @@ -151,7 +160,6 @@ class DeviceController : public QObject { // A bounded supervisor timeout must be able to retire stale host-side // deployment state even when the serial peer sends no terminal response. - // The next PROFILE BEGIN safely replaces any firmware staging transaction. void abandonProfileDeployment() { if (!profileDeploying_) return; profileDeploying_ = false; @@ -211,28 +219,23 @@ class DeviceController : public QObject { void profileStateChanged(); void ptpStateChanged(); void deviceMessage(const QString& message); + void portReleased(const QString& portName); protected: - bool sendQuietCommand(const QString& command) { - if (!serial_.isOpen()) return false; - const QByteArray bytes = command.toUtf8() + '\n'; - if (serial_.write(bytes) < 0) { - setError(serial_.errorString()); - return false; - } - return true; - } + bool sendQuietCommand(const QString& command); + bool sendCommandBatch(const QStringList& commands, bool quiet = false); + void setSessionHeartbeatEnabled(bool enabled); private: - bool connectPortInternal(const QString& portName, bool automatic); - bool tryNextProbe(); - bool sendIdentifyProbe(); bool sendCommand(const QString& command); + void connectWorkerSignals(); + void handlePortSnapshot(const QStringList& ports, const QString& recommendedPort, int highConfidenceCount); + void handlePortOpened(const QString& portName, bool automatic); + void handlePortClosed(const QString& portName); void applyIdentity(DeviceIdentity identity); void clearIdentity(); void markDeviceVerified(); void setIdentificationState(IdentificationState state); - void finishIdentificationTimeout(); void setDiscoveryState(const QString& status, bool active); void setRunning(bool value); void setError(const QString& message); @@ -243,14 +246,12 @@ class DeviceController : public QObject { static QString utf8Hex(const QString& text); static QString compactMac(const QString& text); - QSerialPort serial_; - QTimer verificationTimer_; + DeviceIoWorker* ioWorker_{nullptr}; + QThread ioThread_; QStringList ports_; QString recommendedPort_; - QStringList probeQueue_; QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; DeviceIdentity identity_; - QByteArray pendingRx_; QString lastError_; QString logText_; QString fps_{QStringLiteral("—")}; @@ -259,17 +260,22 @@ class DeviceController : public QObject { QString signalGeneration_{QStringLiteral("—")}; QString profileGeneration_{QStringLiteral("—")}; QString lastIdentificationPort_; + QString portName_; + QString pendingConnectPort_; IdentificationState identificationState_{IdentificationState::Idle}; int identifyAttempts_{0}; + int highConfidenceCount_{0}; bool running_{false}; + bool connected_{false}; bool discovering_{false}; bool deviceVerified_{false}; - bool automaticConnection_{false}; - bool genericProbeActive_{false}; bool profileArmed_{false}; bool profileDeploying_{false}; bool ptpAvailable_{false}; bool ptpRunning_{false}; + bool ioWorkerReady_{false}; + bool ioWorkerAffinityValid_{false}; + bool pendingAutoDetect_{false}; double signalFrequencyHz_{50.0}; QString ptpStatus_{QStringLiteral("Waiting for device")}; QString ptpDomain_{QStringLiteral("-")}; From 80c4163b403f1f3e9a6bc9295a77c8678748407e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:23:08 +0700 Subject: [PATCH 177/272] refactor(studio): move serial ownership behind DeviceIoWorker facade --- apps/arstack_studio/src/DeviceController.cpp | 483 ++++++++++--------- 1 file changed, 259 insertions(+), 224 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 98d1e9079..e6b64f923 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -2,17 +2,17 @@ #include "DeviceController.hpp" +#include "DeviceIoWorker.hpp" + #include +#include #include -#include -#include #include #include #include namespace { -constexpr qint32 kBaudRate = 115200; const QRegularExpression kAnsiExpression{QStringLiteral("\\x1B\\[[0-9;]*m")}; const QRegularExpression kStateExpression{ QStringLiteral("state=(RUNNING|STOPPED).*generation=(\\d+).*signal_frequency=(\\d+)\\s*mHz"), @@ -42,67 +42,158 @@ const QRegularExpression kPtpConfigExpression{ QStringLiteral("PTP config domain=(\\d+) transportSpecific=0x([0-9A-Fa-f]+) VLAN=(\\S+) Announce=(\\d+) ms Sync=(\\d+) ms Pdelay=(ON|OFF)"), QRegularExpression::CaseInsensitiveOption}; -struct PortCandidate { - QString name; - int confidence{}; -}; - -[[nodiscard]] int portConfidence(const QSerialPortInfo& info) { - int score = 0; - if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100; - const QString identity = QStringLiteral("%1 %2 %3") - .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower(); - if (identity.contains(QStringLiteral("esp32-p4"))) score += 90; - else if (identity.contains(QStringLiteral("esp32"))) score += 65; - if (identity.contains(QStringLiteral("espressif"))) score += 60; - if (identity.contains(QStringLiteral("usb jtag")) || - identity.contains(QStringLiteral("usb serial"))) score += 15; - return score; -} - [[nodiscard]] bool finitePositive(const double value) noexcept { return std::isfinite(value) && value > 0.0; } } // namespace DeviceController::DeviceController(QObject* parent) : QObject(parent) { - connect(&serial_, &QSerialPort::readyRead, this, [this] { - pendingRx_.append(serial_.readAll()); - while (true) { - const auto newline = pendingRx_.indexOf('\n'); - if (newline < 0) break; - QByteArray line = pendingRx_.left(newline); - pendingRx_.remove(0, newline + 1); - if (!line.isEmpty() && line.endsWith('\r')) line.chop(1); - processLine(QString::fromUtf8(line)); + ioWorker_ = new DeviceIoWorker; + ioWorker_->moveToThread(&ioThread_); + ioThread_.setObjectName(QStringLiteral("ARStackDeviceIo")); + + connect(&ioThread_, &QThread::started, ioWorker_, &DeviceIoWorker::initialize); + connect(&ioThread_, &QThread::finished, ioWorker_, &QObject::deleteLater); + connectWorkerSignals(); + ioThread_.start(); +} + +DeviceController::~DeviceController() { + if (ioWorker_ == nullptr || !ioThread_.isRunning()) return; + + const bool requestStop = running_; + if (QThread::currentThread() == &ioThread_) { + ioWorker_->shutdown(requestStop); + } else { + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); }, + Qt::BlockingQueuedConnection); + } + ioThread_.quit(); + ioThread_.wait(); +} + +void DeviceController::connectWorkerSignals() { + connect(ioWorker_, &DeviceIoWorker::ready, this, [this](const bool affinityValid) { + ioWorkerReady_ = true; + ioWorkerAffinityValid_ = affinityValid; + + if (!pendingConnectPort_.isEmpty()) { + const QString requested = std::exchange(pendingConnectPort_, QString{}); + pendingAutoDetect_ = false; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, requested] { worker->connectPort(requested); }, + Qt::QueuedConnection); + return; } + if (pendingAutoDetect_) { + pendingAutoDetect_ = false; + QMetaObject::invokeMethod( + ioWorker_, &DeviceIoWorker::autoDetectAndConnect, Qt::QueuedConnection); + } + }); + + connect(ioWorker_, &DeviceIoWorker::portsObserved, this, + [this](const QStringList& ports, const QString& recommended, const int highConfidenceCount) { + handlePortSnapshot(ports, recommended, highConfidenceCount); }); - connect(&serial_, &QSerialPort::errorOccurred, this, [this](const QSerialPort::SerialPortError error) { - if (error == QSerialPort::NoError) return; - if (error == QSerialPort::ResourceError || error == QSerialPort::DeviceNotFoundError) { - setError(serial_.errorString()); - disconnectPort(); - } else if (serial_.isOpen()) { - setError(serial_.errorString()); + connect(ioWorker_, &DeviceIoWorker::openingPort, this, + [this](const QString& port, const bool automatic) { + Q_UNUSED(port) + setDiscoveryState( + automatic + ? QStringLiteral("Checking connected device for an ARStack injector...") + : QStringLiteral("Verifying ARStack injector identity..."), + true); + }); + + connect(ioWorker_, &DeviceIoWorker::portOpened, this, + [this](const QString& port, const bool automatic) { + handlePortOpened(port, automatic); + }); + + connect(ioWorker_, &DeviceIoWorker::portOpenFailed, this, + [this](const QString& port, const QString& message, const bool automatic) { + Q_UNUSED(port) + if (!automatic) { + setIdentificationState(IdentificationState::Idle); + setDiscoveryState(QStringLiteral("The selected serial port could not be opened."), false); + setError(message); } }); - verificationTimer_.setSingleShot(true); - verificationTimer_.setInterval(identityRetryIntervalMs()); - connect(&verificationTimer_, &QTimer::timeout, this, [this] { - if (!serial_.isOpen() || deviceVerified_ || - identificationState_ != IdentificationState::Identifying) { + connect(ioWorker_, &DeviceIoWorker::portClosed, this, + [this](const QString& port) { handlePortClosed(port); }); + + connect(ioWorker_, &DeviceIoWorker::portReleased, this, + [this](const QString& port) { emit portReleased(port); }); + + connect(ioWorker_, &DeviceIoWorker::identificationAttempt, this, + [this](const QString& port, const int attempt, const int maximum) { + lastIdentificationPort_ = port; + identifyAttempts_ = attempt; + setIdentificationState(IdentificationState::Identifying); + setDiscoveryState( + QStringLiteral("Verifying ARStack semantic identity… attempt %1/%2") + .arg(attempt) + .arg(maximum), + true); + }); + + connect(ioWorker_, &DeviceIoWorker::identificationTimedOut, this, + [this](const QString& port, const int attempts, const bool continuing) { + lastIdentificationPort_ = port; + identifyAttempts_ = attempts; + if (continuing) { + setIdentificationState(IdentificationState::Identifying); + setDiscoveryState(QStringLiteral("Checking connected devices for an ARStack injector..."), true); return; } - if (identityRetryAllowed(identifyAttempts_) && sendIdentifyProbe()) { - verificationTimer_.start(); - return; + setIdentificationState(IdentificationState::Unidentified); + setDiscoveryState( + QStringLiteral("No ARStack semantic identity received from %1 after %2 bounded attempts.") + .arg(port.isEmpty() ? QStringLiteral("the selected port") : port) + .arg(attempts), + false); + }); + + connect(ioWorker_, &DeviceIoWorker::automaticProbeExhausted, this, [this] { + if (identificationState_ != IdentificationState::Unidentified) { + identifyAttempts_ = 0; + setIdentificationState(IdentificationState::Idle); + } + setDiscoveryState( + ports_.isEmpty() + ? QStringLiteral("No verified injector found; setpoints remain editable offline.") + : QStringLiteral("No ARStack injector answered; manual port selection remains available."), + false); + }); + + connect(ioWorker_, &DeviceIoWorker::lineReceived, this, + [this](const QString& line) { processLine(line); }); + + connect(ioWorker_, &DeviceIoWorker::commandTransmitted, this, + [this](const QString& command, const bool quiet) { + if (!quiet) appendLog(QStringLiteral("→"), command); + }); + + connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const QString& message) { + if (profileDeploying_) { + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); } - finishIdentificationTimeout(); + setError(message); }); - refreshPorts(); + connect(ioWorker_, &DeviceIoWorker::transportError, this, + [this](const QString& message, const bool fatal) { + Q_UNUSED(fatal) + setError(message); + }); } QStringList DeviceController::ports() const { return ports_; } @@ -120,9 +211,9 @@ QString DeviceController::firmwareVersion() const { return identity_.firmwareVer QString DeviceController::bootId() const { return identity_.bootId; } QStringList DeviceController::capabilities() const { return identity_.capabilities; } DeviceIdentity DeviceController::deviceIdentity() const { return identity_; } -bool DeviceController::connected() const noexcept { return serial_.isOpen(); } +bool DeviceController::connected() const noexcept { return connected_; } bool DeviceController::running() const noexcept { return running_; } -QString DeviceController::portName() const { return serial_.portName(); } +QString DeviceController::portName() const { return portName_; } QString DeviceController::lastError() const { return lastError_; } QString DeviceController::logText() const { return logText_; } QString DeviceController::fps() const { return fps_; } @@ -189,27 +280,62 @@ bool DeviceController::identitySupportsCurrentContract( } void DeviceController::refreshPorts() { - QList candidates; - for (const auto& info : QSerialPortInfo::availablePorts()) { - candidates.push_back({info.portName(), portConfidence(info)}); + if (!ioWorkerReady_ || ioWorker_ == nullptr) return; + QMetaObject::invokeMethod(ioWorker_, &DeviceIoWorker::refreshPorts, Qt::QueuedConnection); +} + +bool DeviceController::autoDetectAndConnect() { + if (deviceVerified_) return true; + if (connected_ || discovering_) return false; + + pendingConnectPort_.clear(); + pendingAutoDetect_ = !ioWorkerReady_; + setDiscoveryState(QStringLiteral("Looking for an ARStack ESP32-P4 injector..."), true); + + if (ioWorkerReady_ && ioWorker_ != nullptr) { + QMetaObject::invokeMethod( + ioWorker_, &DeviceIoWorker::autoDetectAndConnect, Qt::QueuedConnection); + } + return true; +} + +bool DeviceController::connectPort(const QString& portName) { + const QString requested = portName.trimmed(); + if (requested.isEmpty()) { + setError(QStringLiteral("Select a serial port first.")); + return false; } - std::sort(candidates.begin(), candidates.end(), [](const PortCandidate& left, const PortCandidate& right) { - if (left.confidence != right.confidence) return left.confidence > right.confidence; - return left.name.localeAwareCompare(right.name) < 0; - }); - QStringList discovered; - QStringList highConfidence; - for (const auto& candidate : candidates) { - discovered.push_back(candidate.name); - if (candidate.confidence >= 60) highConfidence.push_back(candidate.name); + pendingAutoDetect_ = false; + setDiscoveryState(QStringLiteral("Verifying ARStack injector identity..."), true); + if (!ioWorkerReady_) { + pendingConnectPort_ = requested; + return true; } - discovered.removeDuplicates(); - const QString recommended = highConfidence.size() == 1 ? highConfidence.front() : QString{}; - const bool portsDidChange = discovered != ports_; - const bool recommendationChanged = recommended != recommendedPort_; - ports_ = std::move(discovered); - recommendedPort_ = recommended; + + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, requested] { worker->connectPort(requested); }, + Qt::QueuedConnection); + return true; +} + +void DeviceController::disconnectPort() { + pendingAutoDetect_ = false; + pendingConnectPort_.clear(); + if (!ioWorkerReady_ || ioWorker_ == nullptr) return; + QMetaObject::invokeMethod(ioWorker_, &DeviceIoWorker::disconnectPort, Qt::QueuedConnection); +} + +void DeviceController::handlePortSnapshot( + const QStringList& ports, + const QString& recommendedPort, + const int highConfidenceCount) { + const bool portsDidChange = ports != ports_; + const bool recommendationChanged = recommendedPort != recommendedPort_; + ports_ = ports; + recommendedPort_ = recommendedPort; + highConfidenceCount_ = highConfidenceCount; if (identificationState_ == IdentificationState::Unidentified && !lastIdentificationPort_.isEmpty() && !ports_.contains(lastIdentificationPort_)) { @@ -221,14 +347,14 @@ void DeviceController::refreshPorts() { if (portsDidChange) emit portsChanged(); if (recommendationChanged) emit discoveryChanged(); - if (!serial_.isOpen() && !discovering_) { + if (!connected_ && !discovering_) { if (identificationState_ == IdentificationState::Unidentified) { setDiscoveryState( QStringLiteral("ARStack identity was not received. Retry identification or choose firmware setup explicitly."), false); } else if (!recommendedPort_.isEmpty()) { setDiscoveryState(QStringLiteral("Compatible ESP32-P4 USB device found."), false); - } else if (highConfidence.size() > 1) { + } else if (highConfidenceCount_ > 1) { setDiscoveryState(QStringLiteral("Multiple Espressif devices found; choose the intended injector."), false); } else { setDiscoveryState(QStringLiteral("No verified injector found; setpoints remain editable offline."), false); @@ -236,121 +362,57 @@ void DeviceController::refreshPorts() { } } -bool DeviceController::connectPort(const QString& portName) { - return connectPortInternal(portName, false); -} - -bool DeviceController::autoDetectAndConnect() { - if (deviceVerified_) return true; - refreshPorts(); - probeQueue_.clear(); - genericProbeActive_ = false; - if (!recommendedPort_.isEmpty()) { - setDiscoveryState( - QStringLiteral("Compatible device found. Verifying ARStack identity..."), true); - return connectPortInternal(recommendedPort_, true); - } - if (ports_.isEmpty()) return false; - - probeQueue_ = ports_; - genericProbeActive_ = true; - return tryNextProbe(); -} - -bool DeviceController::tryNextProbe() { - while (!probeQueue_.isEmpty()) { - const QString port = probeQueue_.takeFirst(); - setDiscoveryState(QStringLiteral("Checking connected devices for an ARStack injector..."), true); - if (connectPortInternal(port, true)) return true; - } - genericProbeActive_ = false; - setDiscoveryState(QStringLiteral("No ARStack injector answered; manual port selection remains available."), false); - return false; -} - -bool DeviceController::connectPortInternal(const QString& portName, const bool automatic) { - const QString requestedPort = portName.trimmed(); - if (requestedPort.isEmpty()) { - setError(QStringLiteral("Select a serial port first.")); - return false; - } - if (serial_.isOpen()) disconnectPort(); - - if (!automatic) { - probeQueue_.clear(); - genericProbeActive_ = false; - } - automaticConnection_ = automatic; - deviceVerified_ = false; +void DeviceController::handlePortOpened(const QString& portName, const bool automatic) { + Q_UNUSED(automatic) + const bool wasVerified = deviceVerified_; + connected_ = true; + portName_ = portName; + lastIdentificationPort_ = portName; identifyAttempts_ = 0; - lastIdentificationPort_ = requestedPort; + deviceVerified_ = false; clearIdentity(); - setDiscoveryState(QStringLiteral("Verifying ARStack injector identity..."), true); - - serial_.setPortName(requestedPort); - serial_.setBaudRate(kBaudRate); - serial_.setDataBits(QSerialPort::Data8); - serial_.setParity(QSerialPort::NoParity); - serial_.setStopBits(QSerialPort::OneStop); - serial_.setFlowControl(QSerialPort::NoFlowControl); - - if (!serial_.open(QIODevice::ReadWrite)) { - automaticConnection_ = false; - setIdentificationState(IdentificationState::Idle); - if (!automatic) { - setDiscoveryState(QStringLiteral("The selected serial port could not be opened."), false); - setError(QStringLiteral("Cannot open %1: %2").arg(requestedPort, serial_.errorString())); - } - return false; - } - - pendingRx_.clear(); - lastError_.clear(); running_ = false; profileArmed_ = false; profileDeploying_ = false; + ptpAvailable_ = false; + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("Waiting for device"); resetTelemetry(); - appendLog(QStringLiteral("•"), QStringLiteral("Connected %1 at 115200 8N1").arg(serial_.portName())); + + if (!lastError_.isEmpty()) { + lastError_.clear(); + emit lastErrorChanged(); + } + + appendLog(QStringLiteral("•"), QStringLiteral("Connected %1 at 115200 8N1").arg(portName_)); setIdentificationState(IdentificationState::Identifying); emit connectedChanged(); emit runningChanged(); - emit lastErrorChanged(); emit profileStateChanged(); - - if (!sendIdentifyProbe()) { - finishIdentificationTimeout(); - return false; - } - verificationTimer_.start(); - return true; + emit ptpStateChanged(); + if (wasVerified) emit deviceVerifiedChanged(); } -void DeviceController::disconnectPort() { - const bool wasConnected = serial_.isOpen(); - verificationTimer_.stop(); - if (serial_.isOpen()) serial_.close(); - pendingRx_.clear(); - if (running_) { - running_ = false; - emit runningChanged(); - } - if (profileDeploying_ || profileArmed_) { - profileDeploying_ = false; - profileArmed_ = false; - emit profileStateChanged(); - } - resetTelemetry(); +void DeviceController::handlePortClosed(const QString& portName) { + const bool wasConnected = connected_; const bool wasVerified = deviceVerified_; + connected_ = false; + if (!portName.isEmpty()) portName_ = portName; + running_ = false; + profileDeploying_ = false; + profileArmed_ = false; deviceVerified_ = false; clearIdentity(); - discovering_ = false; - automaticConnection_ = false; identifyAttempts_ = 0; - lastIdentificationPort_.clear(); setIdentificationState(IdentificationState::Idle); + discovering_ = false; ptpAvailable_ = false; ptpRunning_ = false; ptpStatus_ = QStringLiteral("Waiting for device"); + resetTelemetry(); + + emit runningChanged(); + emit profileStateChanged(); emit discoveryChanged(); emit ptpStateChanged(); if (wasVerified) emit deviceVerifiedChanged(); @@ -468,10 +530,6 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { if (includeDataSet) flags |= 0x1U; if (includeSampleRate) flags |= 0x2U; - profileDeploying_ = true; - profileArmed_ = false; - emit profileStateChanged(); - const QStringList commands{ QStringLiteral("PROFILE BEGIN"), QStringLiteral("PROFILE ID %1").arg(idHex), @@ -484,12 +542,13 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { QStringLiteral("PROFILE SHOW"), }; - for (const auto& command : commands) { - if (!sendCommand(command)) { - profileDeploying_ = false; - emit profileStateChanged(); - return false; - } + profileDeploying_ = true; + profileArmed_ = false; + emit profileStateChanged(); + if (!sendCommandBatch(commands)) { + profileDeploying_ = false; + emit profileStateChanged(); + return false; } return true; } @@ -555,32 +614,35 @@ void DeviceController::clearLog() { emit logTextChanged(); } -bool DeviceController::sendIdentifyProbe() { - if (!serial_.isOpen() || !identityRetryAllowed(identifyAttempts_)) return false; - ++identifyAttempts_; - emit identificationStateChanged(); - setDiscoveryState( - QStringLiteral("Verifying ARStack semantic identity… attempt %1/%2") - .arg(identifyAttempts_) - .arg(identityMaxAttempts()), - true); - return sendCommand(QStringLiteral("IDENTIFY")); +bool DeviceController::sendCommand(const QString& command) { + return sendCommandBatch({command}, false); } -bool DeviceController::sendCommand(const QString& command) { - if (!serial_.isOpen()) { +bool DeviceController::sendQuietCommand(const QString& command) { + return sendCommandBatch({command}, true); +} + +bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet) { + if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr) { setError(QStringLiteral("Device is not connected.")); return false; } - const QByteArray bytes = command.toUtf8() + '\n'; - if (serial_.write(bytes) < 0) { - setError(serial_.errorString()); - return false; - } - appendLog(QStringLiteral("→"), command); + const QStringList copy = commands; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, copy, quiet] { worker->enqueueCommands(copy, quiet); }, + Qt::QueuedConnection); return true; } +void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { + if (!ioWorkerReady_ || ioWorker_ == nullptr) return; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, enabled] { worker->setHeartbeatEnabled(enabled); }, + Qt::QueuedConnection); +} + void DeviceController::setRunning(const bool value) { if (running_ == value) return; running_ = value; @@ -593,33 +655,6 @@ void DeviceController::setIdentificationState(const IdentificationState state) { emit identificationStateChanged(); } -void DeviceController::finishIdentificationTimeout() { - if (deviceVerified_) return; - - const QString timedOutPort = serial_.portName().trimmed().isEmpty() - ? lastIdentificationPort_ - : serial_.portName(); - const int attempts = identifyAttempts_; - const bool wasAutomatic = automaticConnection_; - const bool continueProbing = wasAutomatic && genericProbeActive_ && !probeQueue_.isEmpty(); - - disconnectPort(); - lastIdentificationPort_ = timedOutPort; - identifyAttempts_ = attempts; - setIdentificationState(IdentificationState::Unidentified); - setDiscoveryState( - QStringLiteral("No ARStack semantic identity received from %1 after %2 bounded attempts.") - .arg(timedOutPort.isEmpty() ? QStringLiteral("the selected port") : timedOutPort) - .arg(attempts), - false); - - if (continueProbing) { - QTimer::singleShot(0, this, [this] { static_cast(tryNextProbe()); }); - return; - } - genericProbeActive_ = false; -} - void DeviceController::setDiscoveryState(const QString& status, const bool active) { if (discoveryStatus_ == status && discovering_ == active) return; discoveryStatus_ = status; @@ -641,16 +676,16 @@ void DeviceController::clearIdentity() { void DeviceController::markDeviceVerified() { if (deviceVerified_) return; - verificationTimer_.stop(); deviceVerified_ = true; - automaticConnection_ = false; - genericProbeActive_ = false; - probeQueue_.clear(); - lastIdentificationPort_ = serial_.portName(); + lastIdentificationPort_ = portName_; setIdentificationState(IdentificationState::Verified); setDiscoveryState(QStringLiteral("ARStack ESP32-P4 identity verified."), false); emit deviceVerifiedChanged(); emit deviceMessage(QStringLiteral("ESP32-P4 recognized. Device is ready.")); + + if (ioWorkerReady_ && ioWorker_ != nullptr) { + QMetaObject::invokeMethod(ioWorker_, &DeviceIoWorker::confirmIdentity, Qt::QueuedConnection); + } static_cast(sendShow()); static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); static_cast(sendPtpShow()); From d04a5bb130aeda88cb9cddfc4cb58f6403925575 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:23:29 +0700 Subject: [PATCH 178/272] fix(studio): keep S2 retry policy authoritative in facade --- apps/arstack_studio/src/DeviceIoWorker.hpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index fb233320f..231944cc6 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include #include #include #include @@ -21,8 +22,6 @@ class DeviceIoWorker final : public QObject { [[nodiscard]] static constexpr int commandQueueCapacity() noexcept { return 64; } [[nodiscard]] static constexpr int presencePollIntervalMs() noexcept { return 750; } - [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } - [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } public slots: From 43aafbb0b8ee64bb5d2777991ebec74ce4aabe49 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:23:59 +0700 Subject: [PATCH 179/272] fix(studio): expose worker retry constants for regression lock --- apps/arstack_studio/src/DeviceIoWorker.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 231944cc6..5942b42bc 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -22,6 +22,8 @@ class DeviceIoWorker final : public QObject { [[nodiscard]] static constexpr int commandQueueCapacity() noexcept { return 64; } [[nodiscard]] static constexpr int presencePollIntervalMs() noexcept { return 750; } + [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } public slots: From 5d5028ae5fc53761ff7048687fb75829dde0d9f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:24:26 +0700 Subject: [PATCH 180/272] refactor(studio): move heartbeat ownership into DeviceIoWorker --- .../src/StudioDeviceController.hpp | 48 +++++-------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 44f1c1dc3..42ba5b68c 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -25,40 +25,23 @@ class StudioDeviceController : public DeviceController { static_cast(flushLiveCommands()); }); - sessionHeartbeatTimer_.setSingleShot(false); - sessionHeartbeatTimer_.setInterval(700); - connect(&sessionHeartbeatTimer_, &QTimer::timeout, this, [this] { - if (!deviceVerified() || !connected() || !currentFirmwareIdentitySeen()) { - sessionHeartbeatTimer_.stop(); - return; - } - if (!sendQuietCommand(QStringLiteral("HEARTBEAT"))) - sessionHeartbeatTimer_.stop(); - }); - connect(this, &DeviceController::connectedChanged, this, [this] { - if (!connected()) { - sessionHeartbeatTimer_.stop(); - clearPendingLiveCommands(); - } + if (!connected()) clearPendingLiveCommands(); + ensureSessionHeartbeat(); }); connect(this, &DeviceController::deviceVerifiedChanged, this, [this] { - if (!deviceVerified()) { - sessionHeartbeatTimer_.stop(); - clearPendingLiveCommands(); - return; - } + if (!deviceVerified()) clearPendingLiveCommands(); ensureSessionHeartbeat(); }); connect(this, &DeviceController::deviceIdentityChanged, this, [this] { - // S1: heartbeat/control permission follows the typed semantic - // identity only. Human diagnostics text is never product state. + // S1 identity still controls permission, but S4 moves the actual + // heartbeat timer/write ownership into DeviceIoWorker. ensureSessionHeartbeat(); }); if (auto* app = QCoreApplication::instance(); app != nullptr) { connect(app, &QCoreApplication::aboutToQuit, this, [this] { - sessionHeartbeatTimer_.stop(); + setSessionHeartbeatEnabled(false); clearPendingLiveCommands(); if (connected() && running()) { static_cast(DeviceController::stop()); @@ -86,14 +69,11 @@ class StudioDeviceController : public DeviceController { return false; } - if (!sendQuietCommand(QStringLiteral("HEARTBEAT"))) { - emit deviceMessage(QStringLiteral("Control session could not be established before Start.")); - return false; - } - if (!sessionHeartbeatTimer_.isActive()) sessionHeartbeatTimer_.start(); - + // Queued invocations to one worker preserve sender order: enabling the + // heartbeat queues an immediate HEARTBEAT before live values and START. + setSessionHeartbeatEnabled(true); if (!flushLiveCommands()) { - emit deviceMessage(QStringLiteral("Latest injection values could not be sent before Start.")); + emit deviceMessage(QStringLiteral("Latest injection values could not be queued before Start.")); return false; } return DeviceController::start(); @@ -175,11 +155,8 @@ class StudioDeviceController : public DeviceController { }; void ensureSessionHeartbeat() { - if (!deviceVerified() || !connected() || !currentFirmwareIdentitySeen()) return; - if (!sessionHeartbeatTimer_.isActive()) { - if (sendQuietCommand(QStringLiteral("HEARTBEAT"))) - sessionHeartbeatTimer_.start(); - } + setSessionHeartbeatEnabled( + deviceVerified() && connected() && currentFirmwareIdentitySeen()); } void scheduleLiveFlush() { @@ -226,7 +203,6 @@ class StudioDeviceController : public DeviceController { } QTimer liveFlushTimer_; - QTimer sessionHeartbeatTimer_; QHash pendingSignals_; double requestedFrequencyHz_{50.0}; double pendingFrequencyHz_{50.0}; From 3f4b5a56aa5e848ccd58d62a953e4c322ec64793 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:24:39 +0700 Subject: [PATCH 181/272] build(studio): compile DeviceIoWorker transport boundary --- apps/arstack_studio/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index e5bb50256..5c8a89cb1 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -21,6 +21,8 @@ qt_add_executable(arstack_studio src/main.cpp src/SclProfileModel.cpp src/SclProfileModel.hpp + src/DeviceIoWorker.cpp + src/DeviceIoWorker.hpp src/DeviceController.cpp src/DeviceController.hpp src/StudioDeviceController.hpp From 37eb8a92438383aa21f385ced8c6a061880ad6b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:26:33 +0700 Subject: [PATCH 182/272] fix(studio): wait for worker port release before firmware probe --- .../src/SmartSessionController.cpp | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 3561559b7..47c90ac20 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -245,26 +245,20 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { } bool SmartSessionController::retryFirmwareUpdate() { - if (firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; + if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; setupError_ = false; setupErrorStatus_.clear(); blankBoardDetected_ = false; updateRequested_ = true; updateReconnectAttempts_ = 0; updateStage_ = UpdateStage::probing; - if (device_ != nullptr && device_->connected()) device_->disconnectPort(); setPresentation( QStringLiteral("UPDATING FIRMWARE"), QStringLiteral("Checking ESP32-P4 Download mode…"), false, false); - if (!firmware_->probeTarget(updatePort_)) { - latchFirmwareFailure(firmware_->status()); - emit firmwareUpdateFinished(false); - reconcile(); - return false; - } - return true; + continueFirmwareUpdate(); + return updateRequested_; } bool SmartSessionController::retryFirmwareSetup() { @@ -299,12 +293,20 @@ void SmartSessionController::continueFirmwareUpdate() { } updateStage_ = UpdateStage::probing; - if (device_->connected()) device_->disconnectPort(); setPresentation( QStringLiteral("UPDATING FIRMWARE"), QStringLiteral("Verifying ESP32-P4 before firmware installation…"), false, false); + + // S4 serial close is asynchronous. Never let espflash race the worker's + // COM handle: wait for DeviceController::portReleased before ROM probing. + // S5 will add the explicit PortOwner/session-generation protocol. + if (device_->connected()) { + device_->disconnectPort(); + return; + } + if (!firmware_->probeTarget(updatePort_)) { latchFirmwareFailure(firmware_->status()); emit firmwareUpdateFinished(false); @@ -357,6 +359,12 @@ void SmartSessionController::reconnectDeviceSignals() { } reconcile(); }); + connect(device_, &DeviceController::portReleased, this, [this](const QString&) { + if (updateRequested_ && updateStage_ == UpdateStage::probing && + firmware_ != nullptr && !firmware_->busy()) { + QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); + } + }); connect(device_, &DeviceController::profileStateChanged, this, [this] { handleProfileStateChanged(); reconcile(); From 7f917b7fd86b91ebfce2ffaba44fde869f9f4ac7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:28:24 +0700 Subject: [PATCH 183/272] test(studio): lock S4 worker affinity and bounded transport policy --- apps/arstack_studio/src/main.cpp | 34 ++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index e141b2b6c..1dca28cfe 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "DeviceIoWorker.hpp" #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" #include "SmartSessionController.hpp" @@ -8,11 +9,14 @@ #include #include #include +#include #include +#include #include #include #include #include +#include #include #include @@ -250,11 +254,18 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { !SmartSessionController::profileGenerationAdvanced( QStringLiteral("2"), QStringLiteral("not-a-generation")); + const bool workerPolicyAligned = + DeviceIoWorker::commandQueueCapacity() == DeviceController::ioCommandQueueCapacity() && + DeviceIoWorker::presencePollIntervalMs() == DeviceController::ioPresencePollIntervalMs() && + DeviceIoWorker::identityMaxAttempts() == DeviceController::identityMaxAttempts() && + DeviceIoWorker::identityRetryIntervalMs() == DeviceController::identityRetryIntervalMs(); + if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || - !rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy) { + !rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy || + !workerPolicyAligned) { qCritical().noquote() - << "S1/S2/S3 control-plane contract: FAIL" + << "S1/S2/S3/S4 control-plane contract: FAIL" << "current=" << currentAccepted << "legacy=" << legacyRejectedAsCurrent << "protocol-legacy=" << protocolLegacyParsed @@ -263,11 +274,26 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { << "missing-firmware=" << rejectsMissingFirmware << "malformed-boot=" << rejectsMalformedBoot << "bounded-identify=" << boundedIdentifyPolicy - << "bounded-profile-sync=" << boundedProfileSyncPolicy; + << "bounded-profile-sync=" << boundedProfileSyncPolicy + << "worker-policy-aligned=" << workerPolicyAligned; return 11; } StudioDeviceController device; + QElapsedTimer workerDeadline; + workerDeadline.start(); + while (!device.ioWorkerReady() && workerDeadline.elapsed() < 1500) { + app.processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(5); + } + const bool workerBoundaryReady = device.ioWorkerReady() && device.ioWorkerAffinityValid(); + if (!workerBoundaryReady) { + qCritical().noquote() + << "S4 DeviceIoWorker boundary: FAIL · ready=" << device.ioWorkerReady() + << "serial-affinity=" << device.ioWorkerAffinityValid(); + return 13; + } + const bool startsIdle = device.identificationState() == DeviceController::IdentificationState::Idle && device.identifyAttempts() == 0; @@ -284,7 +310,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { return 6; } qInfo().noquote() - << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded generation-aware profile sync + unverified START/DEPLOY fail closed"; + << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded profile sync + S4 threaded DeviceIoWorker ownership + unverified START/DEPLOY fail closed"; return 0; } } // namespace From 77d75ba5c6679e8993d7ec66befef119ade176ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:30:59 +0700 Subject: [PATCH 184/272] fix(studio): make profile command batches exclusive in IO worker --- apps/arstack_studio/src/DeviceIoWorker.hpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 5942b42bc..85fca0740 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -34,7 +34,7 @@ public slots: void connectPort(const QString& portName); void disconnectPort(); void confirmIdentity(); - void enqueueCommands(const QStringList& commands, bool quiet); + void enqueueCommands(const QStringList& commands, bool quiet, bool exclusive); void setHeartbeatEnabled(bool enabled); signals: @@ -58,6 +58,7 @@ public slots: QString text; QByteArray bytes; bool quiet{false}; + bool exclusive{false}; }; void refreshPortsInternal(bool forceSignal); @@ -66,7 +67,11 @@ public slots: void closePortInternal(bool emitRelease); bool sendIdentifyProbe(); void finishIdentificationTimeout(); - bool enqueueCommandsInternal(const QStringList& commands, bool quiet, bool reportRejection); + bool enqueueCommandsInternal( + const QStringList& commands, + bool quiet, + bool exclusive, + bool reportRejection); void pumpWriteQueue(); void completeActiveWrite(); void processReadyRead(); @@ -87,6 +92,7 @@ public slots: int highConfidenceCount_{0}; int identifyAttempts_{0}; + int exclusiveCommandsRemaining_{0}; bool initialized_{false}; bool shuttingDown_{false}; bool automaticConnection_{false}; From 926c7efd04b729a37b2485580a761927d51356c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:31:48 +0700 Subject: [PATCH 185/272] fix(studio): enforce exclusive profile queue gate in IO worker --- apps/arstack_studio/src/DeviceIoWorker.cpp | 37 ++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index a42c4bf86..69d6b5942 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -73,8 +73,11 @@ void DeviceIoWorker::initialize() { heartbeatTimer_->stop(); return; } + // Heartbeat is observational/safety maintenance and must never break an + // exclusive profile transaction. If the exclusive gate is active this + // tick is simply coalesced away; the next timer tick retries. static_cast(enqueueCommandsInternal( - {QStringLiteral("HEARTBEAT")}, true, true)); + {QStringLiteral("HEARTBEAT")}, true, false, false)); }); connect(serial_, &QSerialPort::readyRead, this, &DeviceIoWorker::processReadyRead); @@ -126,6 +129,7 @@ void DeviceIoWorker::shutdown(const bool requestStop) { } commandQueue_.clear(); + exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; closePortInternal(true); @@ -218,6 +222,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa identifyAttempts_ = 0; pendingRx_.clear(); commandQueue_.clear(); + exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; lastPort_ = portName.trimmed(); @@ -264,6 +269,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { identifyAttempts_ = 0; pendingRx_.clear(); commandQueue_.clear(); + exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -282,7 +288,8 @@ bool DeviceIoWorker::sendIdentifyProbe() { ++identifyAttempts_; emit identificationAttempt(serial_->portName(), identifyAttempts_, identityMaxAttempts()); - return enqueueCommandsInternal({QStringLiteral("IDENTIFY")}, false, true); + return enqueueCommandsInternal( + {QStringLiteral("IDENTIFY")}, false, false, true); } void DeviceIoWorker::finishIdentificationTimeout() { @@ -314,13 +321,17 @@ void DeviceIoWorker::confirmIdentity() { if (verificationTimer_ != nullptr) verificationTimer_->stop(); } -void DeviceIoWorker::enqueueCommands(const QStringList& commands, const bool quiet) { - static_cast(enqueueCommandsInternal(commands, quiet, true)); +void DeviceIoWorker::enqueueCommands( + const QStringList& commands, + const bool quiet, + const bool exclusive) { + static_cast(enqueueCommandsInternal(commands, quiet, exclusive, true)); } bool DeviceIoWorker::enqueueCommandsInternal( const QStringList& commands, const bool quiet, + const bool exclusive, const bool reportRejection) { if (serial_ == nullptr || !serial_->isOpen()) { if (reportRejection) emit commandRejected(QStringLiteral("Device is not connected.")); @@ -328,6 +339,15 @@ bool DeviceIoWorker::enqueueCommandsInternal( } if (commands.isEmpty()) return true; + if (exclusiveCommandsRemaining_ > 0) { + if (reportRejection) { + emit commandRejected(exclusive + ? QStringLiteral("Another exclusive device transaction is already pending.") + : QStringLiteral("Device configuration is locked while an exclusive profile transaction is pending.")); + } + return false; + } + const int outstanding = commandQueue_.size() + (writeActive_ ? 1 : 0); if (commands.size() > commandQueueCapacity() - outstanding) { if (reportRejection) { @@ -350,9 +370,10 @@ bool DeviceIoWorker::enqueueCommandsInternal( if (reportRejection) emit commandRejected(QStringLiteral("Device command exceeds the bounded transport size.")); return false; } - prepared.push_back(PendingCommand{trimmed, bytes, quiet}); + prepared.push_back(PendingCommand{trimmed, bytes, quiet, exclusive}); } + if (exclusive) exclusiveCommandsRemaining_ = commands.size(); for (auto& command : prepared) commandQueue_.enqueue(std::move(command)); pumpWriteQueue(); return true; @@ -383,6 +404,9 @@ void DeviceIoWorker::completeActiveWrite() { const PendingCommand completed = activeCommand_; writeActive_ = false; activeCommand_ = {}; + if (completed.exclusive && exclusiveCommandsRemaining_ > 0) { + --exclusiveCommandsRemaining_; + } emit commandTransmitted(completed.text, completed.quiet); pumpWriteQueue(); } @@ -433,6 +457,7 @@ void DeviceIoWorker::setHeartbeatEnabled(const bool enabled) { return; } - static_cast(enqueueCommandsInternal({QStringLiteral("HEARTBEAT")}, true, true)); + static_cast(enqueueCommandsInternal( + {QStringLiteral("HEARTBEAT")}, true, false, false)); if (!heartbeatTimer_->isActive()) heartbeatTimer_->start(); } From 56c4d7d66c0bb2740e7f6c6891433733063b47c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:32:29 +0700 Subject: [PATCH 186/272] refactor(studio): expose exclusive worker command batches --- apps/arstack_studio/src/DeviceController.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index b4f126362..aed0548eb 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -223,7 +223,7 @@ class DeviceController : public QObject { protected: bool sendQuietCommand(const QString& command); - bool sendCommandBatch(const QStringList& commands, bool quiet = false); + bool sendCommandBatch(const QStringList& commands, bool quiet = false, bool exclusive = false); void setSessionHeartbeatEnabled(bool enabled); private: From bc0acdc64af6f1d971bebc75e8cbfcb950226d84 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:32:59 +0700 Subject: [PATCH 187/272] fix(studio): preserve facade call while auto-classifying profile batch --- apps/arstack_studio/src/DeviceIoWorker.hpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 85fca0740..e8e46bcde 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -26,6 +26,14 @@ class DeviceIoWorker final : public QObject { [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } + // Preserve the facade's two-argument transport call while recognizing the + // one multi-command transaction that must be exclusive in S4. + void enqueueCommands(const QStringList& commands, bool quiet) { + const bool exclusive = commands.size() > 1 && + commands.front() == QStringLiteral("PROFILE BEGIN"); + enqueueCommands(commands, quiet, exclusive); + } + public slots: void initialize(); void shutdown(bool requestStop); From f2fdc51d0630fa9c74200f03b4c1a58522a5741d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:33:30 +0700 Subject: [PATCH 188/272] fix(studio): keep facade batch API stable after worker exclusivity --- apps/arstack_studio/src/DeviceController.hpp | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index aed0548eb..ec9063e3c 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -119,18 +119,11 @@ class DeviceController : public QObject { [[nodiscard]] static constexpr int ioCommandQueueCapacity() noexcept { return 64; } [[nodiscard]] static constexpr int ioPresencePollIntervalMs() noexcept { return 750; } - // S1 semantic identity contract. The parser accepts the immediately - // preceding ARStack v1 identity grammar without boot_id so an installed - // pre-S1 image is recognized as legacy/updateable rather than "blank". - // Current Studio firmware requires boot_id and the mandatory capabilities. [[nodiscard]] static bool parseIdentityLine(const QString& line, DeviceIdentity& identity); [[nodiscard]] static bool identitySupportsCurrentContract( const DeviceIdentity& identity, const QString& expectedFirmwareVersion); - // S2 identification policy remains the public deterministic contract. S4 - // moves its timer/transport execution into DeviceIoWorker without changing - // the attempt count or cadence. [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } [[nodiscard]] static constexpr bool identityRetryAllowed(const int attemptsSent) noexcept { @@ -158,8 +151,6 @@ class DeviceController : public QObject { Q_INVOKABLE bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); Q_INVOKABLE virtual bool deployProfile(const QVariantMap& profile); - // A bounded supervisor timeout must be able to retire stale host-side - // deployment state even when the serial peer sends no terminal response. void abandonProfileDeployment() { if (!profileDeploying_) return; profileDeploying_ = false; @@ -223,7 +214,7 @@ class DeviceController : public QObject { protected: bool sendQuietCommand(const QString& command); - bool sendCommandBatch(const QStringList& commands, bool quiet = false, bool exclusive = false); + bool sendCommandBatch(const QStringList& commands, bool quiet = false); void setSessionHeartbeatEnabled(bool enabled); private: From d268031f76d6fe05f19cd0c679a50123534047d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:37:55 +0700 Subject: [PATCH 189/272] chore(studio): make DeviceIoWorker move dependency explicit --- apps/arstack_studio/src/DeviceIoWorker.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index e8e46bcde..e0c17ee33 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -6,6 +6,8 @@ #include #include +#include + class QSerialPort; class QTimer; From 987ef8adf7742b27491a585ed8d193f0ea23f1e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:51:54 +0700 Subject: [PATCH 190/272] S5: add generation-aware firmware process worker boundary --- apps/arstack_studio/src/FirmwareWorker.hpp | 58 ++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 apps/arstack_studio/src/FirmwareWorker.hpp diff --git a/apps/arstack_studio/src/FirmwareWorker.hpp b/apps/arstack_studio/src/FirmwareWorker.hpp new file mode 100644 index 000000000..a96cf0956 --- /dev/null +++ b/apps/arstack_studio/src/FirmwareWorker.hpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +class QProcess; +class QTimer; + +// Long-lived owner of the external firmware process. +// +// S5 invariant: QProcess and its launch/operation timers are created only after +// this object has moved to its dedicated thread. Every operation/result carries +// the supervisor generation that authorized it so stale process callbacks can +// never mutate a newer device session. +class FirmwareWorker final : public QObject { + Q_OBJECT + +public: + explicit FirmwareWorker(QObject* parent = nullptr); + +public slots: + void initialize(); + void startOperation( + quint64 generation, + const QString& program, + const QStringList& arguments, + const QProcessEnvironment& environment, + int launchTimeoutMs, + int operationTimeoutMs); + void cancel(quint64 generation); + void shutdown(); + +signals: + void ready(bool processAffinityValid); + void operationStarted(quint64 generation); + void outputReady(quint64 generation, const QString& text); + void operationFinished(quint64 generation, int exitCode, bool normalExit); + void operationRejected(quint64 generation, const QString& message); + void operationLaunchFailed(quint64 generation, const QString& message); + void operationLaunchTimedOut(quint64 generation); + void operationTimedOut(quint64 generation); + void operationCancelled(quint64 generation); + +private: + void finishProcess(int exitCode, bool normalExit); + void clearOperation(); + + QProcess* process_{nullptr}; + QTimer* launchTimer_{nullptr}; + QTimer* operationTimer_{nullptr}; + quint64 generation_{0}; + bool initialized_{false}; + bool shuttingDown_{false}; + bool operationActive_{false}; + bool cancelRequested_{false}; +}; From 8469a42caaba4a01616b78b9b25174c625a686cf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:52:16 +0700 Subject: [PATCH 191/272] S5: implement bounded firmware process worker --- apps/arstack_studio/src/FirmwareWorker.cpp | 172 +++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 apps/arstack_studio/src/FirmwareWorker.cpp diff --git a/apps/arstack_studio/src/FirmwareWorker.cpp b/apps/arstack_studio/src/FirmwareWorker.cpp new file mode 100644 index 000000000..b838a4a53 --- /dev/null +++ b/apps/arstack_studio/src/FirmwareWorker.cpp @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "FirmwareWorker.hpp" + +#include +#include +#include + +namespace { +constexpr int kShutdownWaitMs = 1500; +} + +FirmwareWorker::FirmwareWorker(QObject* parent) : QObject(parent) {} + +void FirmwareWorker::initialize() { + if (initialized_) return; + initialized_ = true; + shuttingDown_ = false; + + process_ = new QProcess(this); + launchTimer_ = new QTimer(this); + operationTimer_ = new QTimer(this); + + process_->setProcessChannelMode(QProcess::SeparateChannels); + launchTimer_->setSingleShot(true); + operationTimer_->setSingleShot(true); + + connect(launchTimer_, &QTimer::timeout, this, [this] { + if (!operationActive_ || process_ == nullptr || process_->state() != QProcess::Starting) return; + const quint64 generation = generation_; + operationActive_ = false; + cancelRequested_ = false; + if (operationTimer_ != nullptr) operationTimer_->stop(); + process_->kill(); + emit operationLaunchTimedOut(generation); + clearOperation(); + }); + + connect(operationTimer_, &QTimer::timeout, this, [this] { + if (!operationActive_ || process_ == nullptr) return; + const quint64 generation = generation_; + operationActive_ = false; + cancelRequested_ = false; + if (launchTimer_ != nullptr) launchTimer_->stop(); + process_->kill(); + emit operationTimedOut(generation); + clearOperation(); + }); + + connect(process_, &QProcess::started, this, [this] { + if (!operationActive_ || shuttingDown_) return; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->start(); + emit operationStarted(generation_); + }); + + connect(process_, &QProcess::readyReadStandardOutput, this, [this] { + if (!operationActive_ || process_ == nullptr) return; + const QString text = QString::fromUtf8(process_->readAllStandardOutput()); + if (!text.isEmpty()) emit outputReady(generation_, text); + }); + connect(process_, &QProcess::readyReadStandardError, this, [this] { + if (!operationActive_ || process_ == nullptr) return; + const QString text = QString::fromUtf8(process_->readAllStandardError()); + if (!text.isEmpty()) emit outputReady(generation_, text); + }); + + connect(process_, &QProcess::errorOccurred, this, [this](const QProcess::ProcessError error) { + if (!operationActive_ || shuttingDown_ || process_ == nullptr || error != QProcess::FailedToStart) return; + const quint64 generation = generation_; + const QString message = process_->errorString(); + operationActive_ = false; + cancelRequested_ = false; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->stop(); + emit operationLaunchFailed(generation, message); + clearOperation(); + }); + + connect(process_, qOverload(&QProcess::finished), + this, [this](const int exitCode, const QProcess::ExitStatus status) { + finishProcess(exitCode, status == QProcess::NormalExit); + }); + + emit ready(process_->thread() == QThread::currentThread() && thread() == QThread::currentThread()); +} + +void FirmwareWorker::startOperation( + const quint64 generation, + const QString& program, + const QStringList& arguments, + const QProcessEnvironment& environment, + const int launchTimeoutMs, + const int operationTimeoutMs) { + if (!initialized_ || shuttingDown_ || process_ == nullptr) { + emit operationRejected(generation, QStringLiteral("Firmware worker is not available.")); + return; + } + if (operationActive_ || process_->state() != QProcess::NotRunning) { + emit operationRejected(generation, QStringLiteral("Another firmware process is already running.")); + return; + } + if (generation == 0 || program.trimmed().isEmpty() || launchTimeoutMs <= 0 || operationTimeoutMs <= 0) { + emit operationRejected(generation, QStringLiteral("Invalid firmware operation request.")); + return; + } + + generation_ = generation; + operationActive_ = true; + cancelRequested_ = false; + process_->setProcessEnvironment(environment); + process_->setProgram(program); + process_->setArguments(arguments); + operationTimer_->setInterval(operationTimeoutMs); + launchTimer_->start(launchTimeoutMs); + process_->start(); +} + +void FirmwareWorker::cancel(const quint64 generation) { + if (!operationActive_ || process_ == nullptr || generation != generation_) return; + cancelRequested_ = true; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->stop(); + if (process_->state() == QProcess::NotRunning) { + const quint64 cancelledGeneration = generation_; + operationActive_ = false; + cancelRequested_ = false; + emit operationCancelled(cancelledGeneration); + clearOperation(); + return; + } + process_->kill(); +} + +void FirmwareWorker::finishProcess(const int exitCode, const bool normalExit) { + if (!operationActive_ || shuttingDown_) return; + const quint64 completedGeneration = generation_; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->stop(); + + operationActive_ = false; + if (cancelRequested_) { + cancelRequested_ = false; + emit operationCancelled(completedGeneration); + clearOperation(); + return; + } + + emit operationFinished(completedGeneration, exitCode, normalExit); + clearOperation(); +} + +void FirmwareWorker::clearOperation() { + generation_ = 0; +} + +void FirmwareWorker::shutdown() { + if (!initialized_ || shuttingDown_) return; + shuttingDown_ = true; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->stop(); + operationActive_ = false; + cancelRequested_ = false; + generation_ = 0; + + if (process_ != nullptr && process_->state() != QProcess::NotRunning) { + QObject::disconnect(process_, nullptr, this, nullptr); + process_->kill(); + static_cast(process_->waitForFinished(kShutdownWaitMs)); + } + if (process_ != nullptr) process_->close(); +} From acfab9d65f54fd59f67c51ff7e9b64b1868ee070 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:52:54 +0700 Subject: [PATCH 192/272] S5: compile firmware worker boundary --- apps/arstack_studio/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 5c8a89cb1..65221db0d 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -26,6 +26,8 @@ qt_add_executable(arstack_studio src/DeviceController.cpp src/DeviceController.hpp src/StudioDeviceController.hpp + src/FirmwareWorker.cpp + src/FirmwareWorker.hpp src/FirmwareManager.cpp src/FirmwareManager.hpp src/SmartSessionController.cpp From 22edcdb7c09dd2b9dc1e19f0f1bfc16dbaa01eed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:53:23 +0700 Subject: [PATCH 193/272] S5: make FirmwareManager a generation-aware worker facade --- apps/arstack_studio/src/FirmwareManager.hpp | 38 ++++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 361a381ec..11833c380 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -2,9 +2,10 @@ #pragma once #include -#include #include -#include +#include + +class FirmwareWorker; class FirmwareManager : public QObject { Q_OBJECT @@ -41,10 +42,24 @@ class FirmwareManager : public QObject { [[nodiscard]] QString status() const { return status_; } [[nodiscard]] QString bundleStatus() const { return bundleStatus_; } [[nodiscard]] QString logText() const { return logText_; } + [[nodiscard]] quint64 sessionGeneration() const noexcept { return sessionGeneration_; } + [[nodiscard]] bool workerReady() const noexcept { return workerReady_; } + [[nodiscard]] bool workerAffinityValid() const noexcept { return workerAffinityValid_; } [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); [[nodiscard]] static bool supportsEsp32P4Revision(int major, int minor) noexcept; [[nodiscard]] static int parseFlashProgress(const QString& output); + [[nodiscard]] static constexpr int launchTimeoutMs() noexcept { return 15000; } + [[nodiscard]] static constexpr int probeTimeoutMs() noexcept { return 30000; } + [[nodiscard]] static constexpr int flashTimeoutMs() noexcept { return 180000; } + [[nodiscard]] static constexpr int resetTimeoutMs() noexcept { return 20000; } + [[nodiscard]] static constexpr bool workerEventIsCurrent( + const quint64 activeGeneration, + const quint64 eventGeneration) noexcept { + return activeGeneration != 0 && eventGeneration == activeGeneration; + } + + void setSessionGeneration(quint64 generation) noexcept { sessionGeneration_ = generation; } Q_INVOKABLE void refreshBundle(); Q_INVOKABLE bool probeTarget(const QString& portName); @@ -66,19 +81,20 @@ class FirmwareManager : public QObject { [[nodiscard]] QString flasherPath() const; bool loadManifest(); bool startEspflash(const QStringList& arguments, Operation operation); - void startOperationDeadline(Operation operation); - void finishOperation(int exitCode, QProcess::ExitStatus exitStatus); - void handleProcessError(QProcess::ProcessError error); + int operationTimeoutMs(Operation operation) const noexcept; + void connectWorkerSignals(); + void finishOperation(int exitCode, bool normalExit); + void handleLaunchFailure(const QString& message, bool timeout); void handleOperationTimeout(); + void handleOperationRejected(const QString& message); void updateProgressFromOutput(const QString& text); void appendOperationOutput(const QString& text); void appendLog(const QString& text); void setStatus(const QString& text); void fail(const QString& text); - QProcess process_; - QTimer startupTimer_; - QTimer operationTimer_; + FirmwareWorker* worker_{nullptr}; + QThread workerThread_; Operation operation_{Operation::none}; QString operationOutput_; QString selectedPort_; @@ -91,6 +107,8 @@ class FirmwareManager : public QObject { QString status_{QStringLiteral("Firmware setup ready")}; QString bundleStatus_{QStringLiteral("Checking firmware package...")}; QString logText_; + quint64 sessionGeneration_{1}; + quint64 activeOperationGeneration_{0}; int flashProgress_{-1}; bool bundleReady_{false}; bool flasherAvailable_{false}; @@ -99,4 +117,6 @@ class FirmwareManager : public QObject { bool bootloaderHelpNeeded_{false}; bool cancelRequested_{false}; bool shuttingDown_{false}; -}; \ No newline at end of file + bool workerReady_{false}; + bool workerAffinityValid_{false}; +}; From 4531f3fa3d8e35277f7a0774bcb7d4e1b85db08c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:54:41 +0700 Subject: [PATCH 194/272] S5: move espflash process execution behind FirmwareWorker --- apps/arstack_studio/src/FirmwareManager.cpp | 264 +++++++++++--------- 1 file changed, 148 insertions(+), 116 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 11c8c1686..f698bfb40 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -2,6 +2,8 @@ #include "FirmwareManager.hpp" +#include "FirmwareWorker.hpp" + #include #include #include @@ -10,6 +12,7 @@ #include #include #include +#include #include #include @@ -23,16 +26,6 @@ constexpr auto kPreV3Policy = "pre-v3"; constexpr qsizetype kMaxOperationOutput = 65536; constexpr qsizetype kTrimmedOperationOutput = 49152; -// QProcess::started is the authoritative launch signal. The launch deadline is -// only a bounded backstop for machines where Windows Defender / endpoint -// security performs a cold scan of the bundled executable. The previous 2.5 s -// deadline was short enough to create false failures on real Windows systems. -constexpr int kLaunchTimeoutMs = 15000; -constexpr int kProbeTimeoutMs = 30000; -constexpr int kFlashTimeoutMs = 180000; -constexpr int kResetTimeoutMs = 20000; -constexpr int kShutdownWaitMs = 1500; - QString normalizedHash(const QString& text) { QString hash = text.trimmed().toLower(); hash.remove(QLatin1Char(' ')); @@ -41,65 +34,79 @@ QString normalizedHash(const QString& text) { } // namespace FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { - process_.setProcessChannelMode(QProcess::SeparateChannels); - - startupTimer_.setSingleShot(true); - connect(&startupTimer_, &QTimer::timeout, this, [this] { - if (shuttingDown_ || !busy_ || operation_ == Operation::none) return; - // Never classify a running child as a launch failure. If started() was - // delayed in the event queue, the operation deadline owns it instead. - if (process_.state() != QProcess::Starting) return; - - const Operation failedOperation = operation_; - operation_ = Operation::none; - busy_ = false; - flashProgress_ = -1; - operationTimer_.stop(); - process_.kill(); + worker_ = new FirmwareWorker; + worker_->moveToThread(&workerThread_); + workerThread_.setObjectName(QStringLiteral("ARStackFirmwareWorker")); + connect(&workerThread_, &QThread::started, worker_, &FirmwareWorker::initialize); + connect(&workerThread_, &QThread::finished, worker_, &QObject::deleteLater); + connectWorkerSignals(); + workerThread_.start(); + refreshBundle(); +} - if (failedOperation == Operation::reset) { - bootloaderHelpNeeded_ = false; - setStatus(QStringLiteral("Firmware was written, but the reset tool did not launch. Press RESET once or reconnect USB; Studio will verify the board.")); - emit stateChanged(); - emit installationFinished(false); - return; - } +FirmwareManager::~FirmwareManager() { + shutdown(); +} - bootloaderHelpNeeded_ = false; - fail(QStringLiteral("Bundled firmware tool did not launch within 15 seconds. Windows security may be delaying or blocking it.")); +void FirmwareManager::connectWorkerSignals() { + connect(worker_, &FirmwareWorker::ready, this, [this](const bool affinityValid) { + workerReady_ = true; + workerAffinityValid_ = affinityValid; emit stateChanged(); - emit operationFailed(status_, false); }); - operationTimer_.setSingleShot(true); - connect(&operationTimer_, &QTimer::timeout, this, &FirmwareManager::handleOperationTimeout); - - connect(&process_, &QProcess::started, this, [this] { - startupTimer_.stop(); - if (shuttingDown_ || operation_ == Operation::none) return; - startOperationDeadline(operation_); - }); - connect(&process_, &QProcess::errorOccurred, - this, &FirmwareManager::handleProcessError); - connect(&process_, &QProcess::readyReadStandardOutput, this, [this] { - const QString text = QString::fromUtf8(process_.readAllStandardOutput()); + connect(worker_, &FirmwareWorker::outputReady, this, + [this](const quint64 generation, const QString& text) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; appendOperationOutput(text); updateProgressFromOutput(text); appendLog(text); }); - connect(&process_, &QProcess::readyReadStandardError, this, [this] { - const QString text = QString::fromUtf8(process_.readAllStandardError()); - appendOperationOutput(text); - updateProgressFromOutput(text); - appendLog(text); + + connect(worker_, &FirmwareWorker::operationFinished, this, + [this](const quint64 generation, const int exitCode, const bool normalExit) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + finishOperation(exitCode, normalExit); }); - connect(&process_, qOverload(&QProcess::finished), - this, &FirmwareManager::finishOperation); - refreshBundle(); -} -FirmwareManager::~FirmwareManager() { - shutdown(); + connect(worker_, &FirmwareWorker::operationRejected, this, + [this](const quint64 generation, const QString& message) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + handleOperationRejected(message); + }); + + connect(worker_, &FirmwareWorker::operationLaunchFailed, this, + [this](const quint64 generation, const QString& message) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + handleLaunchFailure(message, false); + }); + + connect(worker_, &FirmwareWorker::operationLaunchTimedOut, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + handleLaunchFailure({}, true); + }); + + connect(worker_, &FirmwareWorker::operationTimedOut, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + handleOperationTimeout(); + }); + + connect(worker_, &FirmwareWorker::operationCancelled, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + const Operation cancelledOperation = operation_; + operation_ = Operation::none; + activeOperationGeneration_ = 0; + cancelRequested_ = false; + busy_ = false; + flashProgress_ = -1; + setStatus(QStringLiteral("Firmware operation cancelled.")); + emit stateChanged(); + if (cancelledOperation == Operation::reset) emit installationFinished(false); + else emit operationFailed(status_, false); + }); } bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, int& minor) { @@ -248,6 +255,10 @@ bool FirmwareManager::probeTarget(const QString& portName) { fail(QStringLiteral("Select the ESP32-P4 USB serial port first.")); return false; } + if (sessionGeneration_ == 0) { + fail(QStringLiteral("Firmware operation has no active supervisor generation.")); + return false; + } selectedPort_ = port; targetVerified_ = false; @@ -275,6 +286,10 @@ bool FirmwareManager::installFirmware(const QString& portName) { fail(QStringLiteral("Check and verify a supported ESP32-P4 pre-v3 board before flashing.")); return false; } + if (sessionGeneration_ == 0) { + fail(QStringLiteral("Firmware operation has no active supervisor generation.")); + return false; + } bootloaderHelpNeeded_ = false; flashProgress_ = 0; @@ -292,43 +307,34 @@ bool FirmwareManager::installFirmware(const QString& portName) { } void FirmwareManager::cancel() { - if (!busy_ || shuttingDown_) return; + if (!busy_ || shuttingDown_ || worker_ == nullptr || activeOperationGeneration_ == 0) return; cancelRequested_ = true; - startupTimer_.stop(); - operationTimer_.stop(); setStatus(QStringLiteral("Cancelling firmware operation...")); emit stateChanged(); - - if (process_.state() == QProcess::NotRunning) { - const Operation cancelledOperation = operation_; - cancelRequested_ = false; - operation_ = Operation::none; - busy_ = false; - flashProgress_ = -1; - setStatus(QStringLiteral("Firmware operation cancelled.")); - emit stateChanged(); - if (cancelledOperation == Operation::reset) emit installationFinished(false); - else emit operationFailed(status_, false); - return; - } - process_.kill(); + const quint64 generation = activeOperationGeneration_; + QMetaObject::invokeMethod( + worker_, + [worker = worker_, generation] { worker->cancel(generation); }, + Qt::QueuedConnection); } void FirmwareManager::shutdown() { if (shuttingDown_) return; shuttingDown_ = true; - startupTimer_.stop(); - operationTimer_.stop(); - cancelRequested_ = true; operation_ = Operation::none; + activeOperationGeneration_ = 0; busy_ = false; + cancelRequested_ = true; - if (process_.state() != QProcess::NotRunning) { - QObject::disconnect(&process_, nullptr, this, nullptr); - process_.kill(); - static_cast(process_.waitForFinished(kShutdownWaitMs)); + if (worker_ != nullptr && workerThread_.isRunning()) { + if (QThread::currentThread() == &workerThread_) { + worker_->shutdown(); + } else { + QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection); + } + workerThread_.quit(); + workerThread_.wait(); } - process_.close(); } void FirmwareManager::clearLog() { @@ -338,81 +344,108 @@ void FirmwareManager::clearLog() { } bool FirmwareManager::startEspflash(const QStringList& arguments, const Operation operation) { - if (shuttingDown_) return false; - if (process_.state() != QProcess::NotRunning) { - fail(QStringLiteral("Another firmware process is already running.")); + if (shuttingDown_ || worker_ == nullptr || !workerThread_.isRunning() || sessionGeneration_ == 0) { + fail(QStringLiteral("Firmware worker is unavailable.")); + busy_ = false; return false; } operation_ = operation; + activeOperationGeneration_ = sessionGeneration_; operationOutput_.clear(); - operationTimer_.stop(); QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); environment.insert(QStringLiteral("ESPFLASH_PORT"), selectedPort_); environment.insert(QStringLiteral("ESPFLASH_SKIP_UPDATE_CHECK"), QStringLiteral("true")); - process_.setProcessEnvironment(environment); - process_.setProgram(flasherPath()); - process_.setArguments(arguments); appendLog(QStringLiteral("$ ESPFLASH_PORT=%1 espflash %2\n") .arg(selectedPort_, arguments.join(QLatin1Char(' ')))); - process_.start(); - startupTimer_.start(kLaunchTimeoutMs); + const quint64 generation = activeOperationGeneration_; + const QString program = flasherPath(); + const int timeout = operationTimeoutMs(operation); + QMetaObject::invokeMethod( + worker_, + [worker = worker_, generation, program, arguments, environment, timeout] { + worker->startOperation( + generation, + program, + arguments, + environment, + FirmwareManager::launchTimeoutMs(), + timeout); + }, + Qt::QueuedConnection); return true; } -void FirmwareManager::startOperationDeadline(const Operation operation) { - int timeoutMs = 0; +int FirmwareManager::operationTimeoutMs(const Operation operation) const noexcept { switch (operation) { case Operation::probe: - timeoutMs = kProbeTimeoutMs; - break; + return probeTimeoutMs(); case Operation::flash: - timeoutMs = kFlashTimeoutMs; - break; + return flashTimeoutMs(); case Operation::reset: - timeoutMs = kResetTimeoutMs; - break; + return resetTimeoutMs(); case Operation::none: - return; + return 1; } - operationTimer_.start(timeoutMs); + return 1; } -void FirmwareManager::handleProcessError(const QProcess::ProcessError error) { - if (shuttingDown_ || error != QProcess::FailedToStart || operation_ == Operation::none) return; +void FirmwareManager::handleOperationRejected(const QString& message) { + const Operation rejected = operation_; + operation_ = Operation::none; + activeOperationGeneration_ = 0; + busy_ = false; + flashProgress_ = -1; + cancelRequested_ = false; - startupTimer_.stop(); - operationTimer_.stop(); + if (rejected == Operation::reset) { + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Firmware was written, but the reset tool could not start. Press RESET once or reconnect USB; Studio will verify the board.")); + emit stateChanged(); + emit installationFinished(false); + return; + } + + bootloaderHelpNeeded_ = false; + fail(message.isEmpty() ? QStringLiteral("Firmware worker rejected the operation.") : message); + emit stateChanged(); + emit operationFailed(status_, false); +} + +void FirmwareManager::handleLaunchFailure(const QString& message, const bool timeout) { const Operation failedOperation = operation_; operation_ = Operation::none; + activeOperationGeneration_ = 0; busy_ = false; flashProgress_ = -1; cancelRequested_ = false; if (failedOperation == Operation::reset) { bootloaderHelpNeeded_ = false; - setStatus(QStringLiteral("Firmware was written, but the reset tool could not launch. Press RESET once or reconnect USB; Studio will verify the board.")); + setStatus(timeout + ? QStringLiteral("Firmware was written, but the reset tool did not launch. Press RESET once or reconnect USB; Studio will verify the board.") + : QStringLiteral("Firmware was written, but the reset tool could not launch. Press RESET once or reconnect USB; Studio will verify the board.")); emit stateChanged(); emit installationFinished(false); return; } bootloaderHelpNeeded_ = false; - fail(QStringLiteral("Unable to launch bundled firmware tool: %1").arg(process_.errorString())); + fail(timeout + ? QStringLiteral("Bundled firmware tool did not launch within 15 seconds. Windows security may be delaying or blocking it.") + : QStringLiteral("Unable to launch bundled firmware tool: %1").arg(message)); emit stateChanged(); emit operationFailed(status_, false); } void FirmwareManager::handleOperationTimeout() { - if (shuttingDown_ || !busy_ || operation_ == Operation::none) return; - const Operation timedOut = operation_; operation_ = Operation::none; + activeOperationGeneration_ = 0; busy_ = false; flashProgress_ = -1; - startupTimer_.stop(); - process_.kill(); + cancelRequested_ = false; if (timedOut == Operation::reset) { bootloaderHelpNeeded_ = false; @@ -432,11 +465,10 @@ void FirmwareManager::handleOperationTimeout() { emit operationFailed(status_, true); } -void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitStatus exitStatus) { - startupTimer_.stop(); - operationTimer_.stop(); +void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) { const Operation completed = operation_; operation_ = Operation::none; + activeOperationGeneration_ = 0; if (completed == Operation::none || shuttingDown_) return; if (cancelRequested_) { @@ -450,7 +482,7 @@ void FirmwareManager::finishOperation(const int exitCode, const QProcess::ExitSt return; } - const bool success = exitStatus == QProcess::NormalExit && exitCode == 0; + const bool success = normalExit && exitCode == 0; if (completed == Operation::probe) { busy_ = false; From b2da411d0184e0eda752df53f2837e3c4bfc9bc1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:55:30 +0700 Subject: [PATCH 195/272] S5: attach session generation to serial worker boundary --- apps/arstack_studio/src/DeviceIoWorker.hpp | 62 ++++++++++++---------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index e0c17ee33..f60acd1b2 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -14,8 +14,9 @@ class QTimer; // Long-lived serial transport owner for ARStack Studio. // // S4 invariant: QSerialPort and every timer that directly drives serial I/O are -// created only after this object has moved to its worker thread. The GUI-facing -// DeviceController facade never opens, closes, reads, or writes a serial handle. +// created only after this object has moved to its dedicated thread. +// S5 invariant: every transport event carries the supervisor generation that +// authorized the session; stale generations are rejected/ignored explicitly. class DeviceIoWorker final : public QObject { Q_OBJECT @@ -28,40 +29,45 @@ class DeviceIoWorker final : public QObject { [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } - // Preserve the facade's two-argument transport call while recognizing the - // one multi-command transaction that must be exclusive in S4. - void enqueueCommands(const QStringList& commands, bool quiet) { + void enqueueCommands( + const QStringList& commands, + const bool quiet, + const quint64 generation) { const bool exclusive = commands.size() > 1 && commands.front() == QStringLiteral("PROFILE BEGIN"); - enqueueCommands(commands, quiet, exclusive); + enqueueCommands(commands, quiet, exclusive, generation); } public slots: void initialize(); void shutdown(bool requestStop); - void refreshPorts(); - void autoDetectAndConnect(); - void connectPort(const QString& portName); - void disconnectPort(); - void confirmIdentity(); - void enqueueCommands(const QStringList& commands, bool quiet, bool exclusive); - void setHeartbeatEnabled(bool enabled); + void refreshPorts(quint64 generation); + void autoDetectAndConnect(quint64 generation); + void connectPort(const QString& portName, quint64 generation); + void disconnectPort(quint64 generation); + void confirmIdentity(quint64 generation); + void enqueueCommands( + const QStringList& commands, + bool quiet, + bool exclusive, + quint64 generation); + void setHeartbeatEnabled(bool enabled, quint64 generation); signals: - void ready(bool serialAffinityValid); - void portsObserved(const QStringList& ports, const QString& recommendedPort, int highConfidenceCount); - void openingPort(const QString& portName, bool automatic); - void portOpened(const QString& portName, bool automatic); - void portOpenFailed(const QString& portName, const QString& message, bool automatic); - void portClosed(const QString& portName); - void portReleased(const QString& portName); - void identificationAttempt(const QString& portName, int attempt, int maximum); - void identificationTimedOut(const QString& portName, int attempts, bool continuingProbe); - void automaticProbeExhausted(); - void lineReceived(const QString& line); - void commandTransmitted(const QString& command, bool quiet); - void commandRejected(const QString& message); - void transportError(const QString& message, bool fatal); + void ready(quint64 generation, bool serialAffinityValid); + void portsObserved(quint64 generation, const QStringList& ports, const QString& recommendedPort, int highConfidenceCount); + void openingPort(quint64 generation, const QString& portName, bool automatic); + void portOpened(quint64 generation, const QString& portName, bool automatic); + void portOpenFailed(quint64 generation, const QString& portName, const QString& message, bool automatic); + void portClosed(quint64 generation, const QString& portName); + void portReleased(quint64 generation, const QString& portName); + void identificationAttempt(quint64 generation, const QString& portName, int attempt, int maximum); + void identificationTimedOut(quint64 generation, const QString& portName, int attempts, bool continuingProbe); + void automaticProbeExhausted(quint64 generation); + void lineReceived(quint64 generation, const QString& line); + void commandTransmitted(quint64 generation, const QString& command, bool quiet); + void commandRejected(quint64 generation, const QString& message); + void transportError(quint64 generation, const QString& message, bool fatal); private: struct PendingCommand final { @@ -71,6 +77,7 @@ public slots: bool exclusive{false}; }; + bool adoptGeneration(quint64 generation, bool closeOldSession); void refreshPortsInternal(bool forceSignal); void tryNextProbe(); bool openPortInternal(const QString& portName, bool automatic); @@ -100,6 +107,7 @@ public slots: PendingCommand activeCommand_; QString lastPort_; + quint64 activeGeneration_{0}; int highConfidenceCount_{0}; int identifyAttempts_{0}; int exclusiveCommandsRemaining_{0}; From 911c3b67da6e5b8378cff56206368cbb2c8220da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:56:24 +0700 Subject: [PATCH 196/272] S5: generation-tag all serial worker events --- apps/arstack_studio/src/DeviceIoWorker.cpp | 107 ++++++++++++--------- 1 file changed, 62 insertions(+), 45 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 69d6b5942..53bd4c479 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -10,6 +10,7 @@ #include #include +#include namespace { constexpr qint32 kBaudRate = 115200; @@ -42,8 +43,6 @@ void DeviceIoWorker::initialize() { initialized_ = true; shuttingDown_ = false; - // S4 construction rule: these objects are deliberately created here, - // after DeviceIoWorker has moved to its dedicated thread. serial_ = new QSerialPort(this); verificationTimer_ = new QTimer(this); presenceTimer_ = new QTimer(this); @@ -73,9 +72,6 @@ void DeviceIoWorker::initialize() { heartbeatTimer_->stop(); return; } - // Heartbeat is observational/safety maintenance and must never break an - // exclusive profile transaction. If the exclusive gate is active this - // tick is simply coalesced away; the next timer tick retries. static_cast(enqueueCommandsInternal( {QStringLiteral("HEARTBEAT")}, true, false, false)); }); @@ -98,14 +94,26 @@ void DeviceIoWorker::initialize() { handleSerialFailure(message); return; default: - emit transportError(message, false); + emit transportError(activeGeneration_, message, false); return; } }); presenceTimer_->start(); refreshPortsInternal(true); - emit ready(serial_->thread() == QThread::currentThread() && thread() == QThread::currentThread()); + emit ready(activeGeneration_, + serial_->thread() == QThread::currentThread() && thread() == QThread::currentThread()); +} + +bool DeviceIoWorker::adoptGeneration(const quint64 generation, const bool closeOldSession) { + if (generation == 0) return false; + if (generation == activeGeneration_) return true; + if (serial_ != nullptr && serial_->isOpen()) { + if (!closeOldSession) return false; + closePortInternal(true); + } + activeGeneration_ = generation; + return true; } void DeviceIoWorker::shutdown(const bool requestStop) { @@ -119,8 +127,6 @@ void DeviceIoWorker::shutdown(const bool requestStop) { probeQueue_.clear(); genericProbeActive_ = false; - // Graceful application shutdown is the only place this worker performs a - // short bounded wait. Normal UI/device operations never block the GUI. if (requestStop && serial_ != nullptr && serial_->isOpen()) { const QByteArray stopBytes{"STOP\n"}; if (serial_->write(stopBytes) == stopBytes.size()) { @@ -135,8 +141,12 @@ void DeviceIoWorker::shutdown(const bool requestStop) { closePortInternal(true); } -void DeviceIoWorker::refreshPorts() { +void DeviceIoWorker::refreshPorts(const quint64 generation) { if (!initialized_ || shuttingDown_) return; + if (generation != 0 && generation != activeGeneration_) { + if (serial_ != nullptr && serial_->isOpen()) return; + activeGeneration_ = generation; + } refreshPortsInternal(true); } @@ -165,12 +175,12 @@ void DeviceIoWorker::refreshPortsInternal(const bool forceSignal) { recommendedPort_ = recommended; highConfidenceCount_ = highConfidence.size(); if (forceSignal || changed) { - emit portsObserved(ports_, recommendedPort_, highConfidenceCount_); + emit portsObserved(activeGeneration_, ports_, recommendedPort_, highConfidenceCount_); } } -void DeviceIoWorker::autoDetectAndConnect() { - if (!initialized_ || shuttingDown_) return; +void DeviceIoWorker::autoDetectAndConnect(const quint64 generation) { + if (!initialized_ || shuttingDown_ || !adoptGeneration(generation, true)) return; if (serial_ != nullptr && serial_->isOpen()) return; refreshPortsInternal(true); @@ -178,11 +188,11 @@ void DeviceIoWorker::autoDetectAndConnect() { genericProbeActive_ = false; if (!recommendedPort_.isEmpty()) { - if (!openPortInternal(recommendedPort_, true)) emit automaticProbeExhausted(); + if (!openPortInternal(recommendedPort_, true)) emit automaticProbeExhausted(activeGeneration_); return; } if (ports_.isEmpty()) { - emit automaticProbeExhausted(); + emit automaticProbeExhausted(activeGeneration_); return; } @@ -191,13 +201,13 @@ void DeviceIoWorker::autoDetectAndConnect() { tryNextProbe(); } -void DeviceIoWorker::connectPort(const QString& portName) { - if (!initialized_ || shuttingDown_) return; +void DeviceIoWorker::connectPort(const QString& portName, const quint64 generation) { + if (!initialized_ || shuttingDown_ || !adoptGeneration(generation, true)) return; probeQueue_.clear(); genericProbeActive_ = false; const QString requested = portName.trimmed(); if (requested.isEmpty()) { - emit portOpenFailed({}, QStringLiteral("Select a serial port first."), false); + emit portOpenFailed(activeGeneration_, {}, QStringLiteral("Select a serial port first."), false); return; } static_cast(openPortInternal(requested, false)); @@ -210,11 +220,11 @@ void DeviceIoWorker::tryNextProbe() { if (openPortInternal(next, true)) return; } genericProbeActive_ = false; - emit automaticProbeExhausted(); + emit automaticProbeExhausted(activeGeneration_); } bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automatic) { - if (serial_ == nullptr || portName.trimmed().isEmpty()) return false; + if (serial_ == nullptr || activeGeneration_ == 0 || portName.trimmed().isEmpty()) return false; if (serial_->isOpen()) closePortInternal(true); automaticConnection_ = automatic; @@ -227,7 +237,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa activeCommand_ = {}; lastPort_ = portName.trimmed(); - emit openingPort(lastPort_, automatic); + emit openingPort(activeGeneration_, lastPort_, automatic); serial_->setPortName(lastPort_); serial_->setBaudRate(kBaudRate); @@ -238,12 +248,12 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa if (!serial_->open(QIODevice::ReadWrite)) { const QString message = QStringLiteral("Cannot open %1: %2").arg(lastPort_, serial_->errorString()); - emit portOpenFailed(lastPort_, message, automatic); + emit portOpenFailed(activeGeneration_, lastPort_, message, automatic); automaticConnection_ = false; return false; } - emit portOpened(lastPort_, automatic); + emit portOpened(activeGeneration_, lastPort_, automatic); if (!sendIdentifyProbe()) { finishIdentificationTimeout(); return false; @@ -252,8 +262,8 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa return true; } -void DeviceIoWorker::disconnectPort() { - if (!initialized_) return; +void DeviceIoWorker::disconnectPort(const quint64 generation) { + if (!initialized_ || generation == 0 || generation != activeGeneration_) return; probeQueue_.clear(); genericProbeActive_ = false; automaticConnection_ = false; @@ -273,21 +283,23 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { writeActive_ = false; activeCommand_ = {}; + const quint64 generation = activeGeneration_; const bool wasOpen = serial_->isOpen(); const QString releasedPort = serial_->portName().trimmed().isEmpty() ? lastPort_ : serial_->portName(); if (wasOpen) serial_->close(); - if (wasOpen) emit portClosed(releasedPort); - if (emitRelease && wasOpen) emit portReleased(releasedPort); + if (wasOpen) emit portClosed(generation, releasedPort); + if (emitRelease && wasOpen) emit portReleased(generation, releasedPort); } bool DeviceIoWorker::sendIdentifyProbe() { - if (serial_ == nullptr || !serial_->isOpen() || identityConfirmed_ || + if (serial_ == nullptr || !serial_->isOpen() || identityConfirmed_ || activeGeneration_ == 0 || identifyAttempts_ >= identityMaxAttempts()) { return false; } ++identifyAttempts_; - emit identificationAttempt(serial_->portName(), identifyAttempts_, identityMaxAttempts()); + emit identificationAttempt( + activeGeneration_, serial_->portName(), identifyAttempts_, identityMaxAttempts()); return enqueueCommandsInternal( {QStringLiteral("IDENTIFY")}, false, false, true); } @@ -295,6 +307,7 @@ bool DeviceIoWorker::sendIdentifyProbe() { void DeviceIoWorker::finishIdentificationTimeout() { if (serial_ == nullptr || identityConfirmed_) return; + const quint64 generation = activeGeneration_; const QString timedOutPort = serial_->portName().trimmed().isEmpty() ? lastPort_ : serial_->portName(); @@ -302,7 +315,7 @@ void DeviceIoWorker::finishIdentificationTimeout() { const bool continueProbing = automaticConnection_ && genericProbeActive_ && !probeQueue_.isEmpty(); closePortInternal(true); - emit identificationTimedOut(timedOutPort, attempts, continueProbing); + emit identificationTimedOut(generation, timedOutPort, attempts, continueProbing); if (continueProbing) { QTimer::singleShot(0, this, &DeviceIoWorker::tryNextProbe); @@ -312,8 +325,8 @@ void DeviceIoWorker::finishIdentificationTimeout() { automaticConnection_ = false; } -void DeviceIoWorker::confirmIdentity() { - if (serial_ == nullptr || !serial_->isOpen()) return; +void DeviceIoWorker::confirmIdentity(const quint64 generation) { + if (serial_ == nullptr || !serial_->isOpen() || generation != activeGeneration_) return; identityConfirmed_ = true; automaticConnection_ = false; genericProbeActive_ = false; @@ -324,7 +337,12 @@ void DeviceIoWorker::confirmIdentity() { void DeviceIoWorker::enqueueCommands( const QStringList& commands, const bool quiet, - const bool exclusive) { + const bool exclusive, + const quint64 generation) { + if (generation == 0 || generation != activeGeneration_) { + emit commandRejected(generation, QStringLiteral("Stale device-session generation rejected.")); + return; + } static_cast(enqueueCommandsInternal(commands, quiet, exclusive, true)); } @@ -334,14 +352,14 @@ bool DeviceIoWorker::enqueueCommandsInternal( const bool exclusive, const bool reportRejection) { if (serial_ == nullptr || !serial_->isOpen()) { - if (reportRejection) emit commandRejected(QStringLiteral("Device is not connected.")); + if (reportRejection) emit commandRejected(activeGeneration_, QStringLiteral("Device is not connected.")); return false; } if (commands.isEmpty()) return true; if (exclusiveCommandsRemaining_ > 0) { if (reportRejection) { - emit commandRejected(exclusive + emit commandRejected(activeGeneration_, exclusive ? QStringLiteral("Another exclusive device transaction is already pending.") : QStringLiteral("Device configuration is locked while an exclusive profile transaction is pending.")); } @@ -351,7 +369,7 @@ bool DeviceIoWorker::enqueueCommandsInternal( const int outstanding = commandQueue_.size() + (writeActive_ ? 1 : 0); if (commands.size() > commandQueueCapacity() - outstanding) { if (reportRejection) { - emit commandRejected(QStringLiteral( + emit commandRejected(activeGeneration_, QStringLiteral( "Device command queue is full; command batch was rejected without partial enqueue.")); } return false; @@ -362,12 +380,12 @@ bool DeviceIoWorker::enqueueCommandsInternal( for (const QString& command : commands) { const QString trimmed = command.trimmed(); if (trimmed.isEmpty() || trimmed.contains(QLatin1Char('\n')) || trimmed.contains(QLatin1Char('\r'))) { - if (reportRejection) emit commandRejected(QStringLiteral("Invalid device command.")); + if (reportRejection) emit commandRejected(activeGeneration_, QStringLiteral("Invalid device command.")); return false; } const QByteArray bytes = trimmed.toUtf8() + '\n'; if (bytes.size() > kMaxCommandBytes) { - if (reportRejection) emit commandRejected(QStringLiteral("Device command exceeds the bounded transport size.")); + if (reportRejection) emit commandRejected(activeGeneration_, QStringLiteral("Device command exceeds the bounded transport size.")); return false; } prepared.push_back(PendingCommand{trimmed, bytes, quiet, exclusive}); @@ -407,7 +425,7 @@ void DeviceIoWorker::completeActiveWrite() { if (completed.exclusive && exclusiveCommandsRemaining_ > 0) { --exclusiveCommandsRemaining_; } - emit commandTransmitted(completed.text, completed.quiet); + emit commandTransmitted(activeGeneration_, completed.text, completed.quiet); pumpWriteQueue(); } @@ -427,13 +445,11 @@ void DeviceIoWorker::processReadyRead() { if (!raw.isEmpty() && raw.endsWith('\r')) raw.chop(1); const QString line = QString::fromUtf8(raw); - // Use the one existing semantic parser to stop the handshake timer in - // the transport thread before a GUI-thread callback can race its expiry. DeviceIdentity identity; if (!identityConfirmed_ && DeviceController::parseIdentityLine(line, identity)) { - confirmIdentity(); + confirmIdentity(activeGeneration_); } - emit lineReceived(line); + emit lineReceived(activeGeneration_, line); } } @@ -441,14 +457,15 @@ void DeviceIoWorker::handleSerialFailure(const QString& message) { const QString detail = message.trimmed().isEmpty() ? QStringLiteral("Serial transport failed.") : message.trimmed(); - emit transportError(detail, true); + emit transportError(activeGeneration_, detail, true); probeQueue_.clear(); genericProbeActive_ = false; automaticConnection_ = false; closePortInternal(true); } -void DeviceIoWorker::setHeartbeatEnabled(const bool enabled) { +void DeviceIoWorker::setHeartbeatEnabled(const bool enabled, const quint64 generation) { + if (generation == 0 || generation != activeGeneration_) return; heartbeatEnabled_ = enabled; if (heartbeatTimer_ == nullptr) return; From 1b0b3e07e3ce9bb07663a52b612926b21a780e6c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:56:57 +0700 Subject: [PATCH 197/272] S5: add generation-aware DeviceController facade --- apps/arstack_studio/src/DeviceController.hpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index ec9063e3c..a7befdd7e 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -112,12 +112,17 @@ class DeviceController : public QObject { [[nodiscard]] QString ptpAnnounceSent() const; [[nodiscard]] QString ptpSyncSent() const; [[nodiscard]] QString ptpTxFailures() const; + [[nodiscard]] quint64 sessionGeneration() const noexcept { return sessionGeneration_; } - // S4 worker-boundary diagnostics used by the native regression gate. [[nodiscard]] bool ioWorkerReady() const noexcept { return ioWorkerReady_; } [[nodiscard]] bool ioWorkerAffinityValid() const noexcept { return ioWorkerAffinityValid_; } [[nodiscard]] static constexpr int ioCommandQueueCapacity() noexcept { return 64; } [[nodiscard]] static constexpr int ioPresencePollIntervalMs() noexcept { return 750; } + [[nodiscard]] static constexpr bool workerEventIsCurrent( + const quint64 activeGeneration, + const quint64 eventGeneration) noexcept { + return activeGeneration != 0 && eventGeneration == activeGeneration; + } [[nodiscard]] static bool parseIdentityLine(const QString& line, DeviceIdentity& identity); [[nodiscard]] static bool identitySupportsCurrentContract( @@ -130,6 +135,8 @@ class DeviceController : public QObject { return attemptsSent >= 0 && attemptsSent < identityMaxAttempts(); } + void setSessionGeneration(quint64 generation); + Q_INVOKABLE void refreshPorts(); Q_INVOKABLE bool autoDetectAndConnect(); Q_INVOKABLE bool connectPort(const QString& portName); @@ -210,7 +217,7 @@ class DeviceController : public QObject { void profileStateChanged(); void ptpStateChanged(); void deviceMessage(const QString& message); - void portReleased(const QString& portName); + void portReleased(quint64 generation, const QString& portName); protected: bool sendQuietCommand(const QString& command); @@ -253,6 +260,7 @@ class DeviceController : public QObject { QString lastIdentificationPort_; QString portName_; QString pendingConnectPort_; + quint64 sessionGeneration_{0}; IdentificationState identificationState_{IdentificationState::Idle}; int identifyAttempts_{0}; int highConfidenceCount_{0}; From eb1f2350fcb0fba059cd7197daa96652f3c804fe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:58:30 +0700 Subject: [PATCH 198/272] S5: filter serial callbacks by supervisor generation --- apps/arstack_studio/src/DeviceController.cpp | 128 ++++++++++++++----- 1 file changed, 97 insertions(+), 31 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index e6b64f923..d155820c4 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -75,33 +75,42 @@ DeviceController::~DeviceController() { } void DeviceController::connectWorkerSignals() { - connect(ioWorker_, &DeviceIoWorker::ready, this, [this](const bool affinityValid) { + connect(ioWorker_, &DeviceIoWorker::ready, this, + [this](const quint64, const bool affinityValid) { ioWorkerReady_ = true; ioWorkerAffinityValid_ = affinityValid; - if (!pendingConnectPort_.isEmpty()) { + const quint64 generation = sessionGeneration_; + if (!pendingConnectPort_.isEmpty() && generation != 0) { const QString requested = std::exchange(pendingConnectPort_, QString{}); pendingAutoDetect_ = false; QMetaObject::invokeMethod( ioWorker_, - [worker = ioWorker_, requested] { worker->connectPort(requested); }, + [worker = ioWorker_, requested, generation] { + worker->connectPort(requested, generation); + }, Qt::QueuedConnection); return; } - if (pendingAutoDetect_) { + if (pendingAutoDetect_ && generation != 0) { pendingAutoDetect_ = false; QMetaObject::invokeMethod( - ioWorker_, &DeviceIoWorker::autoDetectAndConnect, Qt::QueuedConnection); + ioWorker_, + [worker = ioWorker_, generation] { worker->autoDetectAndConnect(generation); }, + Qt::QueuedConnection); } }); connect(ioWorker_, &DeviceIoWorker::portsObserved, this, - [this](const QStringList& ports, const QString& recommended, const int highConfidenceCount) { + [this](const quint64 generation, const QStringList& ports, + const QString& recommended, const int highConfidenceCount) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; handlePortSnapshot(ports, recommended, highConfidenceCount); }); connect(ioWorker_, &DeviceIoWorker::openingPort, this, - [this](const QString& port, const bool automatic) { + [this](const quint64 generation, const QString& port, const bool automatic) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; Q_UNUSED(port) setDiscoveryState( automatic @@ -111,12 +120,15 @@ void DeviceController::connectWorkerSignals() { }); connect(ioWorker_, &DeviceIoWorker::portOpened, this, - [this](const QString& port, const bool automatic) { + [this](const quint64 generation, const QString& port, const bool automatic) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; handlePortOpened(port, automatic); }); connect(ioWorker_, &DeviceIoWorker::portOpenFailed, this, - [this](const QString& port, const QString& message, const bool automatic) { + [this](const quint64 generation, const QString& port, + const QString& message, const bool automatic) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; Q_UNUSED(port) if (!automatic) { setIdentificationState(IdentificationState::Idle); @@ -126,13 +138,21 @@ void DeviceController::connectWorkerSignals() { }); connect(ioWorker_, &DeviceIoWorker::portClosed, this, - [this](const QString& port) { handlePortClosed(port); }); + [this](const quint64 generation, const QString& port) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + handlePortClosed(port); + }); connect(ioWorker_, &DeviceIoWorker::portReleased, this, - [this](const QString& port) { emit portReleased(port); }); + [this](const quint64 generation, const QString& port) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + emit portReleased(generation, port); + }); connect(ioWorker_, &DeviceIoWorker::identificationAttempt, this, - [this](const QString& port, const int attempt, const int maximum) { + [this](const quint64 generation, const QString& port, + const int attempt, const int maximum) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; lastIdentificationPort_ = port; identifyAttempts_ = attempt; setIdentificationState(IdentificationState::Identifying); @@ -144,7 +164,9 @@ void DeviceController::connectWorkerSignals() { }); connect(ioWorker_, &DeviceIoWorker::identificationTimedOut, this, - [this](const QString& port, const int attempts, const bool continuing) { + [this](const quint64 generation, const QString& port, + const int attempts, const bool continuing) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; lastIdentificationPort_ = port; identifyAttempts_ = attempts; if (continuing) { @@ -160,7 +182,9 @@ void DeviceController::connectWorkerSignals() { false); }); - connect(ioWorker_, &DeviceIoWorker::automaticProbeExhausted, this, [this] { + connect(ioWorker_, &DeviceIoWorker::automaticProbeExhausted, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; if (identificationState_ != IdentificationState::Unidentified) { identifyAttempts_ = 0; setIdentificationState(IdentificationState::Idle); @@ -173,14 +197,20 @@ void DeviceController::connectWorkerSignals() { }); connect(ioWorker_, &DeviceIoWorker::lineReceived, this, - [this](const QString& line) { processLine(line); }); + [this](const quint64 generation, const QString& line) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + processLine(line); + }); connect(ioWorker_, &DeviceIoWorker::commandTransmitted, this, - [this](const QString& command, const bool quiet) { + [this](const quint64 generation, const QString& command, const bool quiet) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; if (!quiet) appendLog(QStringLiteral("→"), command); }); - connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const QString& message) { + connect(ioWorker_, &DeviceIoWorker::commandRejected, this, + [this](const quint64 generation, const QString& message) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; if (profileDeploying_) { profileDeploying_ = false; profileArmed_ = false; @@ -190,7 +220,8 @@ void DeviceController::connectWorkerSignals() { }); connect(ioWorker_, &DeviceIoWorker::transportError, this, - [this](const QString& message, const bool fatal) { + [this](const quint64 generation, const QString& message, const bool fatal) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; Q_UNUSED(fatal) setError(message); }); @@ -233,6 +264,13 @@ QString DeviceController::ptpAnnounceSent() const { return ptpAnnounceSent_; } QString DeviceController::ptpSyncSent() const { return ptpSyncSent_; } QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } +void DeviceController::setSessionGeneration(const quint64 generation) { + if (generation == 0 || generation == sessionGeneration_) return; + sessionGeneration_ = generation; + pendingAutoDetect_ = false; + pendingConnectPort_.clear(); +} + bool DeviceController::parseIdentityLine(const QString& line, DeviceIdentity& identity) { const auto match = kIdentityExpression.match(line.trimmed()); if (!match.hasMatch()) return false; @@ -280,21 +318,28 @@ bool DeviceController::identitySupportsCurrentContract( } void DeviceController::refreshPorts() { - if (!ioWorkerReady_ || ioWorker_ == nullptr) return; - QMetaObject::invokeMethod(ioWorker_, &DeviceIoWorker::refreshPorts, Qt::QueuedConnection); + if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, generation] { worker->refreshPorts(generation); }, + Qt::QueuedConnection); } bool DeviceController::autoDetectAndConnect() { if (deviceVerified_) return true; - if (connected_ || discovering_) return false; + if (connected_ || discovering_ || sessionGeneration_ == 0) return false; pendingConnectPort_.clear(); pendingAutoDetect_ = !ioWorkerReady_; setDiscoveryState(QStringLiteral("Looking for an ARStack ESP32-P4 injector..."), true); if (ioWorkerReady_ && ioWorker_ != nullptr) { + const quint64 generation = sessionGeneration_; QMetaObject::invokeMethod( - ioWorker_, &DeviceIoWorker::autoDetectAndConnect, Qt::QueuedConnection); + ioWorker_, + [worker = ioWorker_, generation] { worker->autoDetectAndConnect(generation); }, + Qt::QueuedConnection); } return true; } @@ -305,6 +350,10 @@ bool DeviceController::connectPort(const QString& portName) { setError(QStringLiteral("Select a serial port first.")); return false; } + if (sessionGeneration_ == 0) { + setError(QStringLiteral("Device connection has no active supervisor generation.")); + return false; + } pendingAutoDetect_ = false; setDiscoveryState(QStringLiteral("Verifying ARStack injector identity..."), true); @@ -313,9 +362,12 @@ bool DeviceController::connectPort(const QString& portName) { return true; } + const quint64 generation = sessionGeneration_; QMetaObject::invokeMethod( ioWorker_, - [worker = ioWorker_, requested] { worker->connectPort(requested); }, + [worker = ioWorker_, requested, generation] { + worker->connectPort(requested, generation); + }, Qt::QueuedConnection); return true; } @@ -323,8 +375,12 @@ bool DeviceController::connectPort(const QString& portName) { void DeviceController::disconnectPort() { pendingAutoDetect_ = false; pendingConnectPort_.clear(); - if (!ioWorkerReady_ || ioWorker_ == nullptr) return; - QMetaObject::invokeMethod(ioWorker_, &DeviceIoWorker::disconnectPort, Qt::QueuedConnection); + if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, generation] { worker->disconnectPort(generation); }, + Qt::QueuedConnection); } void DeviceController::handlePortSnapshot( @@ -623,23 +679,29 @@ bool DeviceController::sendQuietCommand(const QString& command) { } bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet) { - if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr) { + if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) { setError(QStringLiteral("Device is not connected.")); return false; } const QStringList copy = commands; + const quint64 generation = sessionGeneration_; QMetaObject::invokeMethod( ioWorker_, - [worker = ioWorker_, copy, quiet] { worker->enqueueCommands(copy, quiet); }, + [worker = ioWorker_, copy, quiet, generation] { + worker->enqueueCommands(copy, quiet, generation); + }, Qt::QueuedConnection); return true; } void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { - if (!ioWorkerReady_ || ioWorker_ == nullptr) return; + if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; + const quint64 generation = sessionGeneration_; QMetaObject::invokeMethod( ioWorker_, - [worker = ioWorker_, enabled] { worker->setHeartbeatEnabled(enabled); }, + [worker = ioWorker_, enabled, generation] { + worker->setHeartbeatEnabled(enabled, generation); + }, Qt::QueuedConnection); } @@ -683,8 +745,12 @@ void DeviceController::markDeviceVerified() { emit deviceVerifiedChanged(); emit deviceMessage(QStringLiteral("ESP32-P4 recognized. Device is ready.")); - if (ioWorkerReady_ && ioWorker_ != nullptr) { - QMetaObject::invokeMethod(ioWorker_, &DeviceIoWorker::confirmIdentity, Qt::QueuedConnection); + if (ioWorkerReady_ && ioWorker_ != nullptr && sessionGeneration_ != 0) { + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, generation] { worker->confirmIdentity(generation); }, + Qt::QueuedConnection); } static_cast(sendShow()); static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); From dcddcd6719f4805ccc7be98972fbb16ecec8129f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 16:59:34 +0700 Subject: [PATCH 199/272] S5: add supervisor session generation and explicit PortOwner --- .../src/SmartSessionController.hpp | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index d7c187c9b..183711aeb 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -29,8 +29,17 @@ class SmartSessionController : public QObject { Q_PROPERTY(QString firmwareSetupPort READ firmwareSetupPort NOTIFY stateChanged) Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT) Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged) + Q_PROPERTY(qulonglong sessionGeneration READ sessionGeneration NOTIFY stateChanged) + Q_PROPERTY(PortOwner portOwner READ portOwner NOTIFY stateChanged) public: + enum class PortOwner { + none, + deviceSession, + firmwareTool, + }; + Q_ENUM(PortOwner) + explicit SmartSessionController(QObject* parent = nullptr); [[nodiscard]] QObject* device() const noexcept; @@ -50,19 +59,13 @@ class SmartSessionController : public QObject { [[nodiscard]] QString firmwareSetupPort() const; [[nodiscard]] QString expectedFirmwareVersion() const; [[nodiscard]] QString deviceFirmwareVersion() const; + [[nodiscard]] quint64 sessionGeneration() const noexcept { return sessionGeneration_; } + [[nodiscard]] PortOwner portOwner() const noexcept { return portOwner_; } - // Recovery is never classified automatically through the ROM bootloader. - // Normal startup first exhausts the bounded semantic IDENTIFY contract. - // Only an explicit user firmware action may then call FirmwareManager's - // read-only ROM target probe before any write is permitted. [[nodiscard]] static QString chooseRecoveryPort( const QString& recommendedPort, const QStringList& visiblePorts); - // S3 profile synchronization is a bounded transaction. A deployment must - // produce a numeric armed generation newer than the baseline when a - // baseline is known. One controlled retry is permitted; after that the - // supervisor enters a terminal, user-retryable PROFILE SYNC ERROR state. [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 2; } [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 2500; } [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { @@ -72,6 +75,18 @@ class SmartSessionController : public QObject { const QString& baseline, const QString& observed) noexcept; + [[nodiscard]] static constexpr bool generationIsCurrent( + const quint64 activeGeneration, + const quint64 eventGeneration) noexcept { + return activeGeneration != 0 && eventGeneration == activeGeneration; + } + [[nodiscard]] static quint64 nextSessionGeneration(quint64 current) noexcept; + [[nodiscard]] static constexpr bool firmwareOwnershipValid( + const PortOwner owner, + const bool serialConnected) noexcept { + return owner == PortOwner::firmwareTool && !serialConnected; + } + void setDevice(QObject* object); void setProfiles(QObject* object); void setFirmware(QObject* object); @@ -92,7 +107,15 @@ class SmartSessionController : public QObject { void firmwareUpdateFinished(bool success); private: - enum class UpdateStage { idle, stopping, probing, flashing, reconnecting, waitingForBootloader }; + enum class UpdateStage { + idle, + stopping, + releasingPort, + probing, + flashing, + reconnecting, + waitingForBootloader, + }; enum class ProfileSyncStage { idle, deploying, failed }; void reconnectDeviceSignals(); @@ -106,6 +129,10 @@ class SmartSessionController : public QObject { void setPresentation(QString state, QString status, bool startReady, bool firmwareUpdateRequired); void refreshFirmwareIdentity(); void continueFirmwareUpdate(); + bool startFirmwareProbe(); + bool startDeviceDiscovery(); + quint64 advanceSessionGeneration(); + void setPortOwner(PortOwner owner); bool firmwareIsCurrent() const; void resetProfileSync(bool requireSync); bool beginProfileSync(const QVariantMap& profile); @@ -129,6 +156,8 @@ class SmartSessionController : public QObject { QString profileSyncBaselineGeneration_; QString profileSyncBootId_; QString profileSyncError_; + quint64 sessionGeneration_{0}; + quint64 pendingReleaseGeneration_{0}; bool startReady_{false}; bool firmwareUpdateRequired_{false}; bool started_{false}; @@ -139,6 +168,7 @@ class SmartSessionController : public QObject { bool setupError_{false}; int updateReconnectAttempts_{0}; int profileSyncAttempts_{0}; + PortOwner portOwner_{PortOwner::none}; UpdateStage updateStage_{UpdateStage::idle}; ProfileSyncStage profileSyncStage_{ProfileSyncStage::idle}; }; From d40b2750891beb44a02fa245b0ac35d9c2c381e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 17:01:12 +0700 Subject: [PATCH 200/272] S5: enforce generation-aware PortOwner handoff --- .../src/SmartSessionController.cpp | 183 +++++++++++++----- 1 file changed, 136 insertions(+), 47 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 47c90ac20..955020f65 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #ifndef ARSTACK_STUDIO_VERSION @@ -28,6 +29,11 @@ QString SmartSessionController::chooseRecoveryPort( return visiblePorts.front().trimmed(); } +quint64 SmartSessionController::nextSessionGeneration(const quint64 current) noexcept { + if (current == std::numeric_limits::max()) return 1; + return current + 1; +} + bool SmartSessionController::profileGenerationAdvanced( const QString& baseline, const QString& observed) noexcept { @@ -49,7 +55,7 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent if (firmware_ != nullptr && firmware_->busy()) return; if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) return; if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { - static_cast(device_->autoDetectAndConnect()); + static_cast(startDeviceDiscovery()); } }); @@ -86,7 +92,7 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent return; } ++updateReconnectAttempts_; - static_cast(device_->autoDetectAndConnect()); + static_cast(startDeviceDiscovery()); reconnectTimer_.start(); reconcile(); }); @@ -107,11 +113,12 @@ bool SmartSessionController::firmwareRetryAvailable() const noexcept { } bool SmartSessionController::profileSyncRetryAvailable() const noexcept { return profileSyncStage_ == ProfileSyncStage::failed && device_ != nullptr && - device_->deviceVerified() && !device_->running(); + device_->deviceVerified() && !device_->running() && portOwner_ == PortOwner::deviceSession; } bool SmartSessionController::updatingFirmware() const noexcept { - return updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::probing || - updateStage_ == UpdateStage::flashing || updateStage_ == UpdateStage::reconnecting; + return updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::releasingPort || + updateStage_ == UpdateStage::probing || updateStage_ == UpdateStage::flashing || + updateStage_ == UpdateStage::reconnecting; } bool SmartSessionController::updateNeedsBootloaderHelp() const noexcept { return updateStage_ == UpdateStage::waitingForBootloader; @@ -125,6 +132,8 @@ QString SmartSessionController::updateStatus() const { switch (updateStage_) { case UpdateStage::stopping: return QStringLiteral("Stopping SMV output safely…"); + case UpdateStage::releasingPort: + return QStringLiteral("Releasing the device serial session before firmware access…"); case UpdateStage::probing: case UpdateStage::flashing: return firmware_->status(); @@ -143,6 +152,30 @@ QString SmartSessionController::firmwareSetupPort() const { return blankBoardPor QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); } QString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; } +quint64 SmartSessionController::advanceSessionGeneration() { + sessionGeneration_ = nextSessionGeneration(sessionGeneration_); + if (device_ != nullptr) device_->setSessionGeneration(sessionGeneration_); + if (firmware_ != nullptr) firmware_->setSessionGeneration(sessionGeneration_); + emit stateChanged(); + return sessionGeneration_; +} + +void SmartSessionController::setPortOwner(const PortOwner owner) { + if (portOwner_ == owner) return; + portOwner_ = owner; + emit stateChanged(); +} + +bool SmartSessionController::startDeviceDiscovery() { + if (device_ == nullptr || portOwner_ == PortOwner::firmwareTool || + device_->connected() || device_->discovering()) { + return false; + } + advanceSessionGeneration(); + setPortOwner(PortOwner::deviceSession); + return device_->autoDetectAndConnect(); +} + void SmartSessionController::setDevice(QObject* object) { auto* next = qobject_cast(object); if (device_ == next) return; @@ -152,6 +185,9 @@ void SmartSessionController::setDevice(QObject* object) { profileSyncBootId_.clear(); resetProfileSync(true); clearBlankBoardContext(); + if (device_ != nullptr) { + advanceSessionGeneration(); + } reconnectDeviceSignals(); emit dependenciesChanged(); reconcile(); @@ -173,6 +209,10 @@ void SmartSessionController::setFirmware(QObject* object) { if (firmware_ == next) return; if (firmware_ != nullptr) disconnect(firmware_, nullptr, this, nullptr); firmware_ = next; + if (firmware_ != nullptr) { + if (sessionGeneration_ == 0) advanceSessionGeneration(); + else firmware_->setSessionGeneration(sessionGeneration_); + } clearBlankBoardContext(); reconnectFirmwareSignals(); emit dependenciesChanged(); @@ -188,7 +228,7 @@ void SmartSessionController::start() { QTimer::singleShot(0, this, [this] { if (!started_ || device_ == nullptr || updateRequested_) return; if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { - static_cast(device_->autoDetectAndConnect()); + static_cast(startDeviceDiscovery()); } refreshRecoveryOfferFromIdentity(); reconcile(); @@ -219,6 +259,7 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { updatePort_ = port; updateRequested_ = true; updateReconnectAttempts_ = 0; + pendingReleaseGeneration_ = 0; profileSyncBootId_.clear(); resetProfileSync(true); setupError_ = false; @@ -251,12 +292,7 @@ bool SmartSessionController::retryFirmwareUpdate() { blankBoardDetected_ = false; updateRequested_ = true; updateReconnectAttempts_ = 0; - updateStage_ = UpdateStage::probing; - setPresentation( - QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Checking ESP32-P4 Download mode…"), - false, - false); + pendingReleaseGeneration_ = 0; continueFirmwareUpdate(); return updateRequested_; } @@ -273,7 +309,7 @@ bool SmartSessionController::retryIdentification() { } clearBlankBoardContext(); - const bool started = device_->autoDetectAndConnect(); + const bool started = startDeviceDiscovery(); reconcile(); return started; } @@ -285,13 +321,17 @@ bool SmartSessionController::retryProfileSync() { return true; } -void SmartSessionController::continueFirmwareUpdate() { - if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; - if (device_->running()) { - updateStage_ = UpdateStage::stopping; - return; +bool SmartSessionController::startFirmwareProbe() { + if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr || + firmware_->busy() || device_->connected()) { + return false; } + // A new firmware attempt gets a fresh generation after the serial handle is + // proven released. This retires every late serial callback before espflash. + advanceSessionGeneration(); + setPortOwner(PortOwner::firmwareTool); + firmware_->setSessionGeneration(sessionGeneration_); updateStage_ = UpdateStage::probing; setPresentation( QStringLiteral("UPDATING FIRMWARE"), @@ -299,19 +339,49 @@ void SmartSessionController::continueFirmwareUpdate() { false, false); - // S4 serial close is asynchronous. Never let espflash race the worker's - // COM handle: wait for DeviceController::portReleased before ROM probing. - // S5 will add the explicit PortOwner/session-generation protocol. - if (device_->connected()) { - device_->disconnectPort(); - return; + if (!firmwareOwnershipValid(portOwner_, device_->connected())) { + latchFirmwareFailure(QStringLiteral("Firmware tool ownership was not established after serial release.")); + emit firmwareUpdateFinished(false); + reconcile(); + return false; } if (!firmware_->probeTarget(updatePort_)) { latchFirmwareFailure(firmware_->status()); emit firmwareUpdateFinished(false); reconcile(); + return false; } + return true; +} + +void SmartSessionController::continueFirmwareUpdate() { + if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; + if (device_->running()) { + updateStage_ = UpdateStage::stopping; + return; + } + + if (portOwner_ == PortOwner::firmwareTool) { + static_cast(startFirmwareProbe()); + return; + } + + if (device_->connected()) { + updateStage_ = UpdateStage::releasingPort; + pendingReleaseGeneration_ = sessionGeneration_; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Releasing the device serial session before firmware access…"), + false, + false); + device_->disconnectPort(); + return; + } + + pendingReleaseGeneration_ = 0; + setPortOwner(PortOwner::none); + static_cast(startFirmwareProbe()); } void SmartSessionController::reconnectDeviceSignals() { @@ -319,6 +389,7 @@ void SmartSessionController::reconnectDeviceSignals() { connect(device_, &DeviceController::deviceVerifiedChanged, this, [this] { if (device_ != nullptr && device_->deviceVerified()) { + if (portOwner_ != PortOwner::firmwareTool) setPortOwner(PortOwner::deviceSession); clearBlankBoardContext(); refreshFirmwareIdentity(); profileSyncBootId_.clear(); @@ -341,6 +412,9 @@ void SmartSessionController::reconnectDeviceSignals() { reconcile(); }); connect(device_, &DeviceController::connectedChanged, this, [this] { + if (device_ != nullptr && device_->connected() && portOwner_ != PortOwner::firmwareTool) { + setPortOwner(PortOwner::deviceSession); + } refreshRecoveryOfferFromIdentity(); reconcile(); }); @@ -359,11 +433,16 @@ void SmartSessionController::reconnectDeviceSignals() { } reconcile(); }); - connect(device_, &DeviceController::portReleased, this, [this](const QString&) { - if (updateRequested_ && updateStage_ == UpdateStage::probing && - firmware_ != nullptr && !firmware_->busy()) { - QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); + connect(device_, &DeviceController::portReleased, this, + [this](const quint64 generation, const QString&) { + if (!updateRequested_ || updateStage_ != UpdateStage::releasingPort || + !generationIsCurrent(sessionGeneration_, generation) || + generation != pendingReleaseGeneration_) { + return; } + pendingReleaseGeneration_ = 0; + setPortOwner(PortOwner::none); + QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); }); connect(device_, &DeviceController::profileStateChanged, this, [this] { handleProfileStateChanged(); @@ -392,6 +471,11 @@ void SmartSessionController::reconnectFirmwareSignals() { return; } + if (firmware_->sessionGeneration() != sessionGeneration_ || + portOwner_ != PortOwner::firmwareTool) { + return; + } + if (updateStage_ == UpdateStage::probing && !firmware_->busy()) { if (firmware_->targetVerified()) { updateStage_ = UpdateStage::flashing; @@ -401,6 +485,14 @@ void SmartSessionController::reconnectFirmwareSignals() { false, false); QTimer::singleShot(0, this, [this] { + if (firmware_ == nullptr || device_ == nullptr || + firmware_->sessionGeneration() != sessionGeneration_ || + !firmwareOwnershipValid(portOwner_, device_->connected())) { + latchFirmwareFailure(QStringLiteral("Firmware ownership changed before installation could start.")); + emit firmwareUpdateFinished(false); + reconcile(); + return; + } if (!firmware_->installFirmware(updatePort_)) { if (firmware_->bootloaderHelpNeeded()) { updateStage_ = UpdateStage::waitingForBootloader; @@ -429,7 +521,9 @@ void SmartSessionController::reconnectFirmwareSignals() { connect(firmware_, &FirmwareManager::operationFailed, this, [this](const QString& message, const bool bootloaderHelpNeeded) { - if (!updateRequested_) { + if (!updateRequested_ || firmware_ == nullptr || + firmware_->sessionGeneration() != sessionGeneration_ || + portOwner_ != PortOwner::firmwareTool) { reconcile(); return; } @@ -443,9 +537,14 @@ void SmartSessionController::reconnectFirmwareSignals() { }); connect(firmware_, &FirmwareManager::installationFinished, this, [this](const bool resetSucceeded) { - if (!updateRequested_) return; + if (!updateRequested_ || firmware_ == nullptr || + firmware_->sessionGeneration() != sessionGeneration_ || + portOwner_ != PortOwner::firmwareTool) { + return; + } updateReconnectAttempts_ = 0; + setPortOwner(PortOwner::none); if (resetSucceeded) { updateStage_ = UpdateStage::reconnecting; setPresentation( @@ -458,6 +557,7 @@ void SmartSessionController::reconnectFirmwareSignals() { } if (firmware_->bootloaderHelpNeeded()) { + setPortOwner(PortOwner::firmwareTool); updateStage_ = UpdateStage::waitingForBootloader; reconcile(); return; @@ -480,9 +580,6 @@ void SmartSessionController::refreshRecoveryOfferFromIdentity() { return; } - // Normal startup stops here: USB descriptor confidence is enough to offer - // recovery, but never enough to classify ROM silicon or authorize a write. - // beginFirmwareInstall() is the explicit boundary that runs probeTarget(). const QString recommended = device_->recommendedPort().trimmed(); if (recommended.isEmpty() || !device_->ports().contains(recommended)) { blankBoardDetected_ = false; @@ -506,7 +603,9 @@ void SmartSessionController::latchFirmwareFailure(QString message) { updateRequested_ = false; updateStage_ = UpdateStage::idle; updateReconnectAttempts_ = 0; + pendingReleaseGeneration_ = 0; blankBoardDetected_ = false; + if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); setupError_ = true; message = message.trimmed(); setupErrorStatus_ = message.isEmpty() @@ -525,6 +624,7 @@ void SmartSessionController::resetProfileSync(const bool requireSync) { bool SmartSessionController::beginProfileSync(const QVariantMap& profile) { if (device_ == nullptr || !device_->deviceVerified() || device_->running() || + portOwner_ != PortOwner::deviceSession || profileSyncStage_ == ProfileSyncStage::failed || !profileSyncRetryAllowed(profileSyncAttempts_)) { return false; @@ -534,9 +634,6 @@ bool SmartSessionController::beginProfileSync(const QVariantMap& profile) { if (profileSyncBootId_ == currentBootId && !currentBootId.isEmpty()) { profileSyncBaselineGeneration_ = device_->profileGeneration().trimmed(); } else { - // A profile generation only has meaning inside one firmware boot. Do - // not compare a fresh boot against a generation cached from an older - // boot/session; the first armed generation on this boot is authoritative. profileSyncBaselineGeneration_.clear(); profileSyncBootId_ = currentBootId; } @@ -566,12 +663,7 @@ void SmartSessionController::handleProfileStateChanged() { if (device_->profileArmed()) { const QString observedGeneration = device_->profileGeneration().trimmed(); - if (!profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) { - // Ignore an out-of-order/stale arm indication. The bounded timer - // remains authoritative and will retry/fail if no new generation - // arrives for this transaction. - return; - } + if (!profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; profileSyncTimer_.stop(); profileSyncStage_ = ProfileSyncStage::idle; @@ -597,9 +689,6 @@ void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { if (reason.isEmpty()) reason = QStringLiteral("Profile synchronization did not complete."); if (profileSyncRetryAllowed(profileSyncAttempts_)) { - // Retire DeviceController's pending marker before retrying. Otherwise a - // silent peer can leave profileDeploying=true forever even though the - // supervisor transaction already timed out. profileSyncStage_ = ProfileSyncStage::idle; profileSyncError_ = reason; if (device_ != nullptr && device_->profileDeploying()) device_->abandonProfileDeployment(); @@ -685,8 +774,8 @@ void SmartSessionController::reconcile() { setPresentation(QStringLiteral("UPDATE NEEDS BOOT"), updateStatus(), false, false); return; } - if (updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::probing || - updateStage_ == UpdateStage::flashing || + if (updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::releasingPort || + updateStage_ == UpdateStage::probing || updateStage_ == UpdateStage::flashing || (updateStage_ == UpdateStage::reconnecting && !device_->deviceVerified())) { setPresentation( QStringLiteral("UPDATING FIRMWARE"), From 52492fb56f2edc33439ed75f33843bba562d8b6a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 17:03:24 +0700 Subject: [PATCH 201/272] S5: lock generation ownership and firmware worker policy in CI --- apps/arstack_studio/src/main.cpp | 65 +++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 1dca28cfe..de67954de 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -135,22 +135,39 @@ int checkFirmwareContract(int argc, char* argv[]) { QString{}, {QStringLiteral("COM3"), QStringLiteral("COM4")}).isEmpty(); + const bool firmwareTimeoutPolicy = + FirmwareManager::launchTimeoutMs() == 15000 && + FirmwareManager::probeTimeoutMs() == 30000 && + FirmwareManager::flashTimeoutMs() == 180000 && + FirmwareManager::resetTimeoutMs() == 20000; + + QElapsedTimer workerDeadline; + workerDeadline.start(); + while (!firmware.workerReady() && workerDeadline.elapsed() < 1500) { + app.processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(5); + } + const bool firmwareWorkerBoundary = firmware.workerReady() && firmware.workerAffinityValid(); + const bool valid = firmware.bundleReady() && firmware.flasherAvailable() && firmware.firmwareVersion() == QStringLiteral(ARSTACK_STUDIO_VERSION) && firmware.expectedProtocol() == QStringLiteral("1") && firmware.firmwareSha256().size() == 64 && realEspflashFormat && dashedFormat && rejectsWrongChip && revisionPolicy && - flashProgressFormat && recoverySelection; + flashProgressFormat && recoverySelection && firmwareTimeoutPolicy && firmwareWorkerBoundary; if (!valid) { qCritical().noquote() - << "Firmware bundle/probe contract: FAIL ·" + << "Firmware bundle/probe/worker contract: FAIL ·" << firmware.bundleStatus() << "espflash-format=" << realEspflashFormat << "dashed-format=" << dashedFormat << "wrong-chip-rejected=" << rejectsWrongChip << "revision-policy=" << revisionPolicy << "progress-format=" << flashProgressFormat - << "recovery-selection=" << recoverySelection; + << "recovery-selection=" << recoverySelection + << "timeout-policy=" << firmwareTimeoutPolicy + << "worker-ready=" << firmware.workerReady() + << "process-affinity=" << firmware.workerAffinityValid(); return 4; } @@ -158,7 +175,7 @@ int checkFirmwareContract(int argc, char* argv[]) { firmware.shutdown(); qInfo().noquote() - << "Firmware bundle/probe contract: PASS · target/revision/progress/hash/recovery/shutdown policy locked ·" + << "Firmware bundle/probe contract: PASS · target/revision/progress/hash policy + generation-aware threaded process boundary locked ·" << firmware.bundleStatus(); return 0; } @@ -167,9 +184,6 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QCoreApplication app(argc, argv); DeviceIdentity currentIdentity; - // Match the real ESP-IDF serial framing. The semantic parser must extract - // the contract from the log prefix without making the human log itself the - // source of product state. const QString currentLine = QStringLiteral( "I (412) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF " @@ -260,12 +274,33 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { DeviceIoWorker::identityMaxAttempts() == DeviceController::identityMaxAttempts() && DeviceIoWorker::identityRetryIntervalMs() == DeviceController::identityRetryIntervalMs(); + const bool generationPolicy = + SmartSessionController::nextSessionGeneration(0) == 1 && + SmartSessionController::nextSessionGeneration(41) == 42 && + SmartSessionController::generationIsCurrent(7, 7) && + !SmartSessionController::generationIsCurrent(7, 6) && + !SmartSessionController::generationIsCurrent(0, 0) && + DeviceController::workerEventIsCurrent(7, 7) && + !DeviceController::workerEventIsCurrent(7, 6) && + FirmwareManager::workerEventIsCurrent(7, 7) && + !FirmwareManager::workerEventIsCurrent(7, 6); + + const bool portOwnershipPolicy = + SmartSessionController::firmwareOwnershipValid( + SmartSessionController::PortOwner::firmwareTool, false) && + !SmartSessionController::firmwareOwnershipValid( + SmartSessionController::PortOwner::firmwareTool, true) && + !SmartSessionController::firmwareOwnershipValid( + SmartSessionController::PortOwner::deviceSession, false) && + !SmartSessionController::firmwareOwnershipValid( + SmartSessionController::PortOwner::none, false); + if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || !rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy || - !workerPolicyAligned) { + !workerPolicyAligned || !generationPolicy || !portOwnershipPolicy) { qCritical().noquote() - << "S1/S2/S3/S4 control-plane contract: FAIL" + << "S1/S2/S3/S4/S5 control-plane contract: FAIL" << "current=" << currentAccepted << "legacy=" << legacyRejectedAsCurrent << "protocol-legacy=" << protocolLegacyParsed @@ -275,7 +310,9 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { << "malformed-boot=" << rejectsMalformedBoot << "bounded-identify=" << boundedIdentifyPolicy << "bounded-profile-sync=" << boundedProfileSyncPolicy - << "worker-policy-aligned=" << workerPolicyAligned; + << "worker-policy-aligned=" << workerPolicyAligned + << "generation-policy=" << generationPolicy + << "port-owner-policy=" << portOwnershipPolicy; return 11; } @@ -294,11 +331,13 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { return 13; } + device.setSessionGeneration(1); const bool startsIdle = device.identificationState() == DeviceController::IdentificationState::Idle && - device.identifyAttempts() == 0; + device.identifyAttempts() == 0 && + device.sessionGeneration() == 1; if (!startsIdle) { - qCritical().noquote() << "S2 identification state: FAIL · controller did not start Idle"; + qCritical().noquote() << "S5 generation-aware identification state: FAIL"; return 12; } if (device.start()) { @@ -310,7 +349,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { return 6; } qInfo().noquote() - << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded profile sync + S4 threaded DeviceIoWorker ownership + unverified START/DEPLOY fail closed"; + << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded profile sync + S4 threaded DeviceIoWorker + S5 generation/PortOwner/FirmwareWorker boundaries + unverified START/DEPLOY fail closed"; return 0; } } // namespace From 3babcb1981dd1e8da215b4ca8fca7763fa9b46d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 17:03:52 +0700 Subject: [PATCH 202/272] S5: make FirmwareManager header dependencies explicit --- apps/arstack_studio/src/FirmwareManager.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 11833c380..5c9adc0e9 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -3,6 +3,7 @@ #include #include +#include #include class FirmwareWorker; From dfe1fd1a344dffc5ba497d29784397d21e42b3ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 17:05:09 +0700 Subject: [PATCH 203/272] S5: drain firmware process output before terminal result --- apps/arstack_studio/src/FirmwareWorker.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/arstack_studio/src/FirmwareWorker.cpp b/apps/arstack_studio/src/FirmwareWorker.cpp index b838a4a53..c440110fd 100644 --- a/apps/arstack_studio/src/FirmwareWorker.cpp +++ b/apps/arstack_studio/src/FirmwareWorker.cpp @@ -138,6 +138,16 @@ void FirmwareWorker::finishProcess(const int exitCode, const bool normalExit) { if (launchTimer_ != nullptr) launchTimer_->stop(); if (operationTimer_ != nullptr) operationTimer_->stop(); + // Drain the terminal buffers before publishing the result. espflash can + // write the chip revision or the final progress token immediately before + // exit, and the facade must see that output before it interprets success. + if (process_ != nullptr) { + const QString out = QString::fromUtf8(process_->readAllStandardOutput()); + const QString err = QString::fromUtf8(process_->readAllStandardError()); + if (!out.isEmpty()) emit outputReady(completedGeneration, out); + if (!err.isEmpty()) emit outputReady(completedGeneration, err); + } + operationActive_ = false; if (cancelRequested_) { cancelRequested_ = false; From 17e51abb2bad170f2e7ef9744e9b88cba224b243 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 17:06:12 +0700 Subject: [PATCH 204/272] S5: fail closed until firmware worker affinity is ready --- apps/arstack_studio/src/FirmwareManager.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index f698bfb40..3814c25c2 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -344,8 +344,9 @@ void FirmwareManager::clearLog() { } bool FirmwareManager::startEspflash(const QStringList& arguments, const Operation operation) { - if (shuttingDown_ || worker_ == nullptr || !workerThread_.isRunning() || sessionGeneration_ == 0) { - fail(QStringLiteral("Firmware worker is unavailable.")); + if (shuttingDown_ || worker_ == nullptr || !workerThread_.isRunning() || + !workerReady_ || !workerAffinityValid_ || sessionGeneration_ == 0) { + fail(QStringLiteral("Firmware worker is unavailable or not affinity-safe.")); busy_ = false; return false; } From 4bdc44233007765d8b399192cd0222c6e379fe4e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 18:30:18 +0700 Subject: [PATCH 205/272] S5.1: bound firmware stop and port-release handoff --- .../src/SmartSessionController.hpp | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 183711aeb..f1e1dd178 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -75,6 +75,12 @@ class SmartSessionController : public QObject { const QString& baseline, const QString& observed) noexcept; + // Firmware handoff is a safety transaction, not an open-ended UI state. + // STOP confirmation and serial release are independently bounded so a lost + // callback cannot leave Studio stuck in UPDATING FIRMWARE forever. + [[nodiscard]] static constexpr int firmwareStopAckTimeoutMs() noexcept { return 3500; } + [[nodiscard]] static constexpr int firmwareReleaseAckTimeoutMs() noexcept { return 2000; } + [[nodiscard]] static constexpr bool generationIsCurrent( const quint64 activeGeneration, const quint64 eventGeneration) noexcept { @@ -118,6 +124,79 @@ class SmartSessionController : public QObject { }; enum class ProfileSyncStage { idle, deploying, failed }; + // Small safety observer, deliberately not a second supervisor. It watches + // only the two acknowledgement barriers owned by this supervisor and + // terminates them if their expected callback never arrives. Repeated + // stateChanged notifications cannot extend a deadline for the same stage. + class FirmwareHandoffWatchdog final { + public: + explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) { + timer_.setSingleShot(true); + QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { + synchronize(); + }); + QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { + expire(); + }); + } + + private: + [[nodiscard]] bool waitingForAck() const noexcept { + if (owner_ == nullptr || !owner_->updateRequested_) return false; + if (owner_->updateStage_ == UpdateStage::stopping) return true; + return owner_->updateStage_ == UpdateStage::releasingPort && + owner_->pendingReleaseGeneration_ != 0; + } + + [[nodiscard]] int timeoutForStage(const UpdateStage stage) const noexcept { + return stage == UpdateStage::stopping + ? firmwareStopAckTimeoutMs() + : firmwareReleaseAckTimeoutMs(); + } + + void synchronize() { + if (!waitingForAck()) { + timer_.stop(); + armedGeneration_ = 0; + armedStage_ = UpdateStage::idle; + return; + } + + if (timer_.isActive() && armedGeneration_ == owner_->sessionGeneration_ && + armedStage_ == owner_->updateStage_) { + return; + } + + armedGeneration_ = owner_->sessionGeneration_; + armedStage_ = owner_->updateStage_; + timer_.start(timeoutForStage(armedStage_)); + } + + void expire() { + if (!waitingForAck() || armedGeneration_ == 0 || + owner_->sessionGeneration_ != armedGeneration_ || + owner_->updateStage_ != armedStage_) { + synchronize(); + return; + } + + const QString reason = armedStage_ == UpdateStage::stopping + ? QStringLiteral( + "Timed out waiting for the injector to confirm STOP. Firmware access was not started; retry explicitly after checking the device connection.") + : QStringLiteral( + "Timed out waiting for the serial worker to acknowledge port release. Firmware access was blocked to prevent COM-port contention."); + + owner_->latchFirmwareFailure(reason); + emit owner_->firmwareUpdateFinished(false); + owner_->reconcile(); + } + + SmartSessionController* owner_{nullptr}; + QTimer timer_; + quint64 armedGeneration_{0}; + UpdateStage armedStage_{UpdateStage::idle}; + }; + void reconnectDeviceSignals(); void reconnectProfileSignals(); void reconnectFirmwareSignals(); @@ -171,4 +250,5 @@ class SmartSessionController : public QObject { PortOwner portOwner_{PortOwner::none}; UpdateStage updateStage_{UpdateStage::idle}; ProfileSyncStage profileSyncStage_{ProfileSyncStage::idle}; + FirmwareHandoffWatchdog firmwareHandoffWatchdog_{this}; }; From b4d9e122d55b090b3460e1bf106fe9099a00bedd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 18:30:39 +0700 Subject: [PATCH 206/272] S5.1: make firmware mutation supervisor-only --- apps/arstack_studio/src/FirmwareManager.hpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 5c9adc0e9..6261ffe55 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -62,13 +62,17 @@ class FirmwareManager : public QObject { void setSessionGeneration(quint64 generation) noexcept { sessionGeneration_ = generation; } + // Read-only/diagnostic QML surface. Mutating firmware operations are + // intentionally C++-only so QML cannot bypass SmartSessionController's + // generation and PortOwner arbitration. Q_INVOKABLE void refreshBundle(); - Q_INVOKABLE bool probeTarget(const QString& portName); - Q_INVOKABLE bool installFirmware(const QString& portName); - Q_INVOKABLE void cancel(); - Q_INVOKABLE void shutdown(); Q_INVOKABLE void clearLog(); + bool probeTarget(const QString& portName); + bool installFirmware(const QString& portName); + void cancel(); + void shutdown(); + signals: void stateChanged(); void logChanged(); @@ -108,7 +112,7 @@ class FirmwareManager : public QObject { QString status_{QStringLiteral("Firmware setup ready")}; QString bundleStatus_{QStringLiteral("Checking firmware package...")}; QString logText_; - quint64 sessionGeneration_{1}; + quint64 sessionGeneration_{0}; quint64 activeOperationGeneration_{0}; int flashProgress_{-1}; bool bundleReady_{false}; From 8f8f7ac2f02b507bb38fdf4cf4d10765caf400da Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 18:31:15 +0700 Subject: [PATCH 207/272] S5.1: remove direct QML firmware ownership bypass --- apps/arstack_studio/qml/FirmwarePanel.qml | 163 ++++++---------------- 1 file changed, 41 insertions(+), 122 deletions(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index b981d61fd..35e0690a3 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -12,57 +12,6 @@ SurfacePanel { property bool technicalDetailsVisible: false readonly property bool hasPort: firmwarePort.currentText.length > 0 - readonly property bool canInstall: - panel.firmware.bundleReady && panel.firmware.targetVerified && - !panel.firmware.busy && panel.hasPort && - firmwarePort.currentText === panel.firmware.selectedPort - - Timer { - id: reconnectTimer - interval: 1800 - repeat: false - onTriggered: panel.device.autoDetectAndConnect() - } - - Timer { - id: autoProbeTimer - interval: 550 - repeat: false - onTriggered: { - if (panel.firmware.busy || panel.firmware.targetVerified) - return - if (panel.device.ports.length !== 1) - return - var port = panel.device.ports[0] - if (!port || port.length === 0) - return - if (panel.device.connected) - panel.device.disconnectPort() - panel.firmware.probeTarget(port) - } - } - - Connections { - target: panel.firmware - function onInstallationFinished(resetSucceeded) { - if (resetSucceeded) - reconnectTimer.restart() - } - } - - Connections { - target: panel.device - function onPortsChanged() { - if (!panel.firmware.targetVerified && !panel.firmware.busy && panel.device.ports.length === 1) - autoProbeTimer.restart() - } - } - - Component.onCompleted: { - panel.device.refreshPorts() - if (panel.device.ports.length === 1) - autoProbeTimer.restart() - } component StepBadge: Rectangle { property string stepText: "1" @@ -115,6 +64,8 @@ SurfacePanel { } } + Component.onCompleted: panel.device.refreshPorts() + ColumnLayout { anchors.fill: parent anchors.margins: 18 @@ -136,7 +87,7 @@ SurfacePanel { font.letterSpacing: 0.9 } Label { - text: "Install ARStack firmware" + text: "Session-controlled firmware service" color: panel.theme.text font.family: panel.uiFont font.pixelSize: 18 @@ -144,9 +95,7 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: panel.device.deviceVerified - ? "The injector is recognized. Reinstall firmware here only when recovery or update is required." - : "For a new board, blank board, or older firmware. The board does not need to identify as ARStack before recovery." + text: "Firmware probing, writing and reset are owned by the guided device session. Advanced view is diagnostic only, preventing a second path from competing for the serial port." color: panel.theme.textSoft font.family: panel.uiFont font.pixelSize: 10 @@ -195,7 +144,7 @@ SurfacePanel { spacing: 1 Label { text: panel.firmware.bundleReady - ? "ARStack firmware v" + panel.firmware.firmwareVersion + " is already included" + ? "ARStack firmware v" + panel.firmware.firmwareVersion + " is verified in this Studio package" : "Firmware package is not ready" color: panel.theme.text font.family: panel.uiFont @@ -205,7 +154,7 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.firmware.bundleReady - ? "No firmware download or .bin selection is required." + ? "Manifest target, revision policy and SHA-256 are checked before the guided session can write." : panel.firmware.bundleStatus color: panel.theme.muted font.family: panel.uiFont @@ -218,11 +167,11 @@ SurfacePanel { Rectangle { Layout.fillWidth: true - implicitHeight: panel.firmware.bootloaderHelpNeeded ? 146 : 126 + implicitHeight: 126 radius: 9 color: panel.theme.surface2 border.width: 1 - border.color: panel.firmware.targetVerified ? "#2c674e" : (panel.firmware.bootloaderHelpNeeded ? "#705827" : panel.theme.lineSoft) + border.color: panel.theme.lineSoft RowLayout { anchors.fill: parent @@ -231,7 +180,7 @@ SurfacePanel { StepBadge { stepText: "1" - complete: panel.firmware.targetVerified + complete: panel.device.deviceVerified Layout.alignment: Qt.AlignTop } @@ -239,7 +188,7 @@ SurfacePanel { Layout.fillWidth: true spacing: 7 Label { - text: "Connect and check the board" + text: "Observe the connected device" color: panel.theme.text font.family: panel.uiFont font.pixelSize: 12 @@ -247,25 +196,14 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: panel.firmware.targetVerified - ? panel.firmware.targetChip + " verified on " + panel.firmware.selectedPort + text: panel.device.deviceVerified + ? panel.device.deviceProduct + " · " + panel.device.deviceTarget + " · firmware v" + panel.device.firmwareVersion : (panel.hasPort - ? firmwarePort.currentText + " is visible in Windows. Studio will check the ESP32-P4 automatically." - : "Connect the ESP32-P4 programming USB cable, then click Refresh.") - color: panel.firmware.targetVerified ? panel.theme.green : panel.theme.muted - font.family: panel.uiFont - font.pixelSize: 10 - wrapMode: Text.WordWrap - } - - Label { - visible: panel.firmware.bootloaderHelpNeeded - Layout.fillWidth: true - text: "ROM not responding: hold BOOT → press and release RESET → release BOOT → click Retry check." - color: panel.theme.amber + ? firmwarePort.currentText + " is visible. Device identity and recovery decisions remain owned by the guided session." + : "Connect the ESP32-P4 programming USB cable, then refresh the port list.") + color: panel.device.deviceVerified ? panel.theme.green : panel.theme.muted font.family: panel.uiFont font.pixelSize: 10 - font.weight: Font.DemiBold wrapMode: Text.WordWrap } @@ -286,21 +224,6 @@ SurfacePanel { enabled: !panel.firmware.busy onClicked: panel.device.refreshPorts() } - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - text: panel.firmware.busy - ? "Checking…" - : (panel.firmware.targetVerified ? "Board verified" : (panel.firmware.bootloaderHelpNeeded ? "Retry check" : "Check board")) - tone: panel.firmware.targetVerified ? "success" : "accent" - enabled: !panel.firmware.busy && panel.hasPort - toolTipText: "Read ESP32-P4 chip identity and silicon revision from the selected COM port" - onClicked: { - if (panel.device.connected) - panel.device.disconnectPort() - panel.firmware.probeTarget(firmwarePort.currentText) - } - } Item { Layout.fillWidth: true } } } @@ -311,9 +234,9 @@ SurfacePanel { Layout.fillWidth: true implicitHeight: 118 radius: 9 - color: panel.canInstall ? "#10251d" : panel.theme.surface2 + color: panel.theme.surface2 border.width: 1 - border.color: panel.canInstall ? "#347a59" : panel.theme.lineSoft + border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.lineSoft RowLayout { anchors.fill: parent @@ -322,7 +245,8 @@ SurfacePanel { StepBadge { stepText: "2" - complete: panel.device.deviceVerified && panel.device.protocolVersion === panel.firmware.expectedProtocol + complete: panel.device.deviceVerified && + panel.device.protocolVersion === panel.firmware.expectedProtocol Layout.alignment: Qt.AlignTop } @@ -330,7 +254,7 @@ SurfacePanel { Layout.fillWidth: true spacing: 5 Label { - text: "Install firmware" + text: panel.firmware.busy ? "Guided firmware operation in progress" : "Use the guided firmware action" color: panel.theme.text font.family: panel.uiFont font.pixelSize: 12 @@ -338,28 +262,31 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: panel.canInstall - ? "Board check passed. Install the bundled firmware; Studio will reset and reconnect automatically." - : "Installation unlocks only after the selected COM port is verified as an allowed ESP32-P4." - color: panel.canInstall ? panel.theme.textSoft : panel.theme.muted + text: panel.firmware.busy + ? "The session supervisor currently owns the firmware tool. Keep USB connected until Studio reports a terminal result." + : "When Studio reports Firmware required or Firmware update, use that guided action from the main workflow. It will stop output, obtain an acknowledged serial release, verify the ESP32-P4, then write." + color: panel.theme.textSoft font.family: panel.uiFont font.pixelSize: 10 wrapMode: Text.WordWrap } } - CalmButton { - theme: panel.theme - uiFont: panel.uiFont - implicitWidth: 190 - implicitHeight: 42 - text: panel.firmware.busy ? "Working…" : "Install ARStack v" + panel.firmware.firmwareVersion - tone: "success" - enabled: panel.canInstall - toolTipText: panel.canInstall - ? "Flash the verified bundled ARStack firmware to " + firmwarePort.currentText - : "Complete Check board first" - onClicked: panel.firmware.installFirmware(firmwarePort.currentText) + Rectangle { + implicitWidth: 150 + implicitHeight: 34 + radius: 7 + color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised + border.width: 1 + border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line + Label { + anchors.centerIn: parent + text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" + color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.Bold + } } } } @@ -395,19 +322,11 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "Protocol, target revision, SHA-256 and flash log" + text: "Protocol, target revision, SHA-256 and firmware-service log" color: panel.theme.muted font.family: panel.uiFont font.pixelSize: 9 } - CalmButton { - visible: panel.firmware.busy - theme: panel.theme - uiFont: panel.uiFont - text: "Cancel" - tone: "danger" - onClicked: panel.firmware.cancel() - } } Rectangle { @@ -440,7 +359,7 @@ SurfacePanel { spacing: 6 RowLayout { Layout.fillWidth: true - Label { text: "FLASH LOG"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.7 } + Label { text: "FIRMWARE SERVICE LOG"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.7 } Item { Layout.fillWidth: true } CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Clear"; onClicked: panel.firmware.clearLog() } } From af7efdb0b6be3c4ac550d6fba89664de33baf2d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 19:05:52 +0700 Subject: [PATCH 208/272] S6: add bounded control health and USB loss detection --- .../src/StudioDeviceController.hpp | 129 +++++++++++++++++- 1 file changed, 126 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 42ba5b68c..8882e37ff 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -16,6 +16,8 @@ class StudioDeviceController : public DeviceController { Q_OBJECT + Q_PROPERTY(bool controlResponsive READ controlResponsive NOTIFY controlHealthChanged) + Q_PROPERTY(int missedHealthReplies READ missedHealthReplies NOTIFY controlHealthChanged) public: explicit StudioDeviceController(QObject* parent = nullptr) : DeviceController(parent) { @@ -25,12 +27,30 @@ class StudioDeviceController : public DeviceController { static_cast(flushLiveCommands()); }); + // S6 health monitoring is intentionally separate from the 700 ms lease + // heartbeat. The heartbeat protects the firmware output failsafe; this + // lower-rate positive SHOW/structured-state exchange proves that the + // bidirectional control path is still responsive. + controlHealthTimer_.setSingleShot(false); + controlHealthTimer_.setInterval(controlHealthProbeIntervalMs()); + connect(&controlHealthTimer_, &QTimer::timeout, this, [this] { + serviceControlHealth(); + }); + connect(this, &DeviceController::connectedChanged, this, [this] { - if (!connected()) clearPendingLiveCommands(); + if (!connected()) { + clearPendingLiveCommands(); + resetControlHealth(); + } ensureSessionHeartbeat(); }); connect(this, &DeviceController::deviceVerifiedChanged, this, [this] { - if (!deviceVerified()) clearPendingLiveCommands(); + if (!deviceVerified()) { + clearPendingLiveCommands(); + resetControlHealth(); + } else { + establishControlHealth(); + } ensureSessionHeartbeat(); }); connect(this, &DeviceController::deviceIdentityChanged, this, [this] { @@ -38,9 +58,29 @@ class StudioDeviceController : public DeviceController { // heartbeat timer/write ownership into DeviceIoWorker. ensureSessionHeartbeat(); }); + connect(this, &DeviceController::telemetryChanged, this, [this] { + // SHOW's machine-parsed state line emits telemetryChanged. Periodic + // firmware telemetry also counts as positive control-path evidence, + // so it naturally suppresses unnecessary health retries while RUNNING. + confirmControlHealth(); + }); + connect(this, &DeviceController::portsChanged, this, [this] { + // QSerialPort errors are still authoritative, but the worker's + // 750 ms enumeration snapshot closes the gap where Windows removes + // a COM device before Qt surfaces ResourceError/DeviceNotFoundError. + const QString activePort = portName().trimmed(); + if (connected() && !activePort.isEmpty() && !ports().contains(activePort)) { + emit deviceMessage(QStringLiteral( + "%1 disappeared from USB/COM enumeration. Releasing the stale session for automatic recovery.") + .arg(activePort)); + disconnectPort(); + } + }); if (auto* app = QCoreApplication::instance(); app != nullptr) { connect(app, &QCoreApplication::aboutToQuit, this, [this] { + controlHealthTimer_.stop(); + healthProbeOutstanding_ = false; setSessionHeartbeatEnabled(false); clearPendingLiveCommands(); if (connected() && running()) { @@ -55,11 +95,20 @@ class StudioDeviceController : public DeviceController { deviceIdentity(), QStringLiteral(ARSTACK_STUDIO_VERSION)); } + [[nodiscard]] bool controlResponsive() const noexcept { return controlResponsive_; } + [[nodiscard]] int missedHealthReplies() const noexcept { return missedHealthReplies_; } + [[nodiscard]] static constexpr int controlHealthProbeIntervalMs() noexcept { return 2000; } + [[nodiscard]] static constexpr int controlHealthMaxMisses() noexcept { return 2; } + Q_INVOKABLE bool start() override { if (!deviceVerified()) { emit deviceMessage(QStringLiteral("Connect and verify the ARStack ESP32-P4 before Start.")); return false; } + if (!controlResponsive_) { + emit deviceMessage(QStringLiteral("Device control health is degraded; wait for recovery before Start.")); + return false; + } if (!currentFirmwareIdentitySeen()) { emit deviceMessage(QStringLiteral("Firmware update required before Start.")); return false; @@ -145,6 +194,10 @@ class StudioDeviceController : public DeviceController { return DeviceController::deployProfile(profile); } +signals: + void controlHealthChanged(); + void controlHealthFailed(); + private: struct PendingSignal { double magnitude{}; @@ -159,6 +212,72 @@ class StudioDeviceController : public DeviceController { deviceVerified() && connected() && currentFirmwareIdentitySeen()); } + void establishControlHealth() { + healthProbeOutstanding_ = false; + missedHealthReplies_ = 0; + setControlResponsive(true); + if (!controlHealthTimer_.isActive()) controlHealthTimer_.start(); + } + + void resetControlHealth() { + controlHealthTimer_.stop(); + healthProbeOutstanding_ = false; + const bool changed = controlResponsive_ || missedHealthReplies_ != 0; + controlResponsive_ = false; + missedHealthReplies_ = 0; + if (changed) emit controlHealthChanged(); + } + + void setControlResponsive(const bool value) { + if (controlResponsive_ == value) return; + controlResponsive_ = value; + emit controlHealthChanged(); + } + + void confirmControlHealth() { + if (!connected() || !deviceVerified()) return; + const bool changed = !controlResponsive_ || missedHealthReplies_ != 0; + healthProbeOutstanding_ = false; + controlResponsive_ = true; + missedHealthReplies_ = 0; + if (changed) emit controlHealthChanged(); + } + + void serviceControlHealth() { + if (!connected() || !deviceVerified()) { + resetControlHealth(); + return; + } + + // Profile deployment is an exclusive transport transaction. Health + // observation is deferred instead of interleaving another command or + // treating intentional queue exclusivity as a missed response. + if (profileDeploying()) return; + + if (healthProbeOutstanding_) { + ++missedHealthReplies_; + setControlResponsive(false); + emit controlHealthChanged(); + if (missedHealthReplies_ >= controlHealthMaxMisses()) { + controlHealthTimer_.stop(); + healthProbeOutstanding_ = false; + emit deviceMessage(QStringLiteral( + "Device control health timed out after bounded probes. Reconnecting without restarting SMV automatically.")); + emit controlHealthFailed(); + disconnectPort(); + return; + } + } + + // SHOW returns a structured state line parsed by DeviceController. The + // request is quiet so the diagnostics log is not polluted by outbound + // health traffic; any normal structured telemetry can satisfy the same + // positive-liveness proof before the next deadline. + if (sendQuietCommand(QStringLiteral("SHOW"))) { + healthProbeOutstanding_ = true; + } + } + void scheduleLiveFlush() { if (!liveFlushTimer_.isActive()) liveFlushTimer_.start(); } @@ -203,8 +322,12 @@ class StudioDeviceController : public DeviceController { } QTimer liveFlushTimer_; + QTimer controlHealthTimer_; QHash pendingSignals_; double requestedFrequencyHz_{50.0}; double pendingFrequencyHz_{50.0}; + int missedHealthReplies_{0}; bool frequencyPending_{false}; -}; + bool controlResponsive_{false}; + bool healthProbeOutstanding_{false}; +}; \ No newline at end of file From 2f7f26fcfcfc440507ef4bd9dd1c0553373394a4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 19:06:55 +0700 Subject: [PATCH 209/272] S6: bind recovery to stable device identity --- .../src/SmartSessionController.hpp | 115 +++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index f1e1dd178..f7c1d8f71 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -1,12 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include "DeviceController.hpp" + +#include #include #include #include #include -class DeviceController; class FirmwareManager; class SclProfileModel; @@ -92,6 +94,14 @@ class SmartSessionController : public QObject { const bool serialConnected) noexcept { return owner == PortOwner::firmwareTool && !serialConnected; } + [[nodiscard]] static bool recoveryIdentityMatches( + const QString& expectedDeviceId, + const QString& observedDeviceId) noexcept { + const QString expected = expectedDeviceId.trimmed(); + const QString observed = observedDeviceId.trimmed(); + return !expected.isEmpty() && !observed.isEmpty() && + expected.compare(observed, Qt::CaseInsensitive) == 0; + } void setDevice(QObject* object); void setProfiles(QObject* object); @@ -197,6 +207,106 @@ class SmartSessionController : public QObject { UpdateStage armedStage_{UpdateStage::idle}; }; + // S6 keeps re-enumeration recovery inside the existing supervisor. This + // observer remembers the semantic device_id of a verified board and only + // permits an automatic post-loss recovery to converge on that same board, + // even when Windows assigns a different COM number after reset/replug. + class DeviceRecoveryMonitor final { + public: + explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) { + QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { + bindDevice(); + }); + } + + private: + void bindDevice() { + QObject::disconnect(verifiedConnection_); + QObject::disconnect(identificationConnection_); + device_ = owner_ != nullptr ? owner_->device_ : nullptr; + wasVerified_ = device_ != nullptr && device_->deviceVerified(); + if (wasVerified_) { + lastVerifiedDeviceId_ = device_->deviceId().trimmed(); + expectedRecoveryDeviceId_ = lastVerifiedDeviceId_; + } + if (device_ == nullptr) return; + + verifiedConnection_ = QObject::connect( + device_, &DeviceController::deviceVerifiedChanged, owner_, [this] { + handleVerificationChange(); + }); + identificationConnection_ = QObject::connect( + device_, &DeviceController::identificationStateChanged, owner_, [this] { + handleIdentificationChange(); + }); + } + + void handleVerificationChange() { + if (owner_ == nullptr || device_ == nullptr) return; + + if (device_->deviceVerified()) { + const QString observed = device_->deviceId().trimmed(); + if (recoveryPending_ && !expectedRecoveryDeviceId_.isEmpty() && + !SmartSessionController::recoveryIdentityMatches( + expectedRecoveryDeviceId_, observed)) { + owner_->blankBoardDetected_ = false; + owner_->blankBoardPort_.clear(); + owner_->setupError_ = true; + owner_->setupErrorStatus_ = QStringLiteral( + "A different ARStack injector (%1) appeared while Studio was recovering device %2. Automatic recovery was blocked; reconnect the intended injector or retry explicitly.") + .arg(observed.isEmpty() ? QStringLiteral("unknown") : observed, + expectedRecoveryDeviceId_); + device_->disconnectPort(); + owner_->reconcile(); + return; + } + + if (!observed.isEmpty()) { + lastVerifiedDeviceId_ = observed; + expectedRecoveryDeviceId_ = observed; + } + recoveryPending_ = false; + wasVerified_ = true; + return; + } + + if (wasVerified_ && !owner_->updateRequested_) { + expectedRecoveryDeviceId_ = lastVerifiedDeviceId_; + recoveryPending_ = !expectedRecoveryDeviceId_.isEmpty(); + owner_->blankBoardDetected_ = false; + owner_->blankBoardPort_.clear(); + owner_->resetProfileSync(true); + } + wasVerified_ = false; + } + + void handleIdentificationChange() { + if (owner_ == nullptr || device_ == nullptr || !recoveryPending_ || + device_->identificationState() != DeviceController::IdentificationState::Unidentified) { + return; + } + + // A timeout while recovering a previously verified injector is not + // evidence of blank firmware. Keep recovery explicit and never turn + // a transient CDC/re-enumeration delay into an Install prompt. + owner_->blankBoardDetected_ = false; + owner_->blankBoardPort_.clear(); + owner_->setupError_ = true; + owner_->setupErrorStatus_ = QStringLiteral( + "The previously verified injector did not answer semantic identity after bounded retries. Firmware absence was not inferred; retry identification after USB settles."); + owner_->reconcile(); + } + + SmartSessionController* owner_{nullptr}; + DeviceController* device_{nullptr}; + QMetaObject::Connection verifiedConnection_; + QMetaObject::Connection identificationConnection_; + QString lastVerifiedDeviceId_; + QString expectedRecoveryDeviceId_; + bool wasVerified_{false}; + bool recoveryPending_{false}; + }; + void reconnectDeviceSignals(); void reconnectProfileSignals(); void reconnectFirmwareSignals(); @@ -251,4 +361,5 @@ class SmartSessionController : public QObject { UpdateStage updateStage_{UpdateStage::idle}; ProfileSyncStage profileSyncStage_{ProfileSyncStage::idle}; FirmwareHandoffWatchdog firmwareHandoffWatchdog_{this}; -}; + DeviceRecoveryMonitor deviceRecoveryMonitor_{this}; +}; \ No newline at end of file From f2db0fb8b8b48fbe6e36281cce725ceb0e8f4693 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 19:07:11 +0700 Subject: [PATCH 210/272] S6: add process-level Studio ownership guard --- .../src/SingleInstanceGuard.hpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/arstack_studio/src/SingleInstanceGuard.hpp diff --git a/apps/arstack_studio/src/SingleInstanceGuard.hpp b/apps/arstack_studio/src/SingleInstanceGuard.hpp new file mode 100644 index 000000000..44ea0c3f8 --- /dev/null +++ b/apps/arstack_studio/src/SingleInstanceGuard.hpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +// Process-level ownership barrier for the desktop instrument. +// +// The lock is intentionally acquired in main() before FirmwareManager, QML, or +// DeviceIoWorker is created. This prevents a second Studio process from ever +// reaching serial-port ownership arbitration for the same injector. +class SingleInstanceGuard final { +public: + explicit SingleInstanceGuard(QString lockPath = lockFilePath()) + : lock_(std::move(lockPath)) {} + + SingleInstanceGuard(const SingleInstanceGuard&) = delete; + SingleInstanceGuard& operator=(const SingleInstanceGuard&) = delete; + + [[nodiscard]] bool tryAcquire() { + if (acquired_) return true; + acquired_ = lock_.tryLock(0); + return acquired_; + } + + [[nodiscard]] bool acquired() const noexcept { return acquired_; } + + [[nodiscard]] static QString lockFilePath() { + QString root = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + if (root.trimmed().isEmpty()) root = QDir::tempPath(); + return QDir(root).filePath(QStringLiteral("arstack-studio-single-instance.lock")); + } + + [[nodiscard]] static constexpr int contentionExitCode() noexcept { return 23; } + +private: + QLockFile lock_; + bool acquired_{false}; +}; \ No newline at end of file From 12d1dd1a586025439bd922fb80c5efa286d3f7c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 19:07:41 +0700 Subject: [PATCH 211/272] S6: include move support in single-instance guard --- apps/arstack_studio/src/SingleInstanceGuard.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/SingleInstanceGuard.hpp b/apps/arstack_studio/src/SingleInstanceGuard.hpp index 44ea0c3f8..632620879 100644 --- a/apps/arstack_studio/src/SingleInstanceGuard.hpp +++ b/apps/arstack_studio/src/SingleInstanceGuard.hpp @@ -6,6 +6,8 @@ #include #include +#include + // Process-level ownership barrier for the desktop instrument. // // The lock is intentionally acquired in main() before FirmwareManager, QML, or From 1ed6136a8ca2690f46a2fb211a483971c359a0c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 19:08:43 +0700 Subject: [PATCH 212/272] S6: enforce single-instance ownership before device services --- apps/arstack_studio/src/main.cpp | 59 ++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index de67954de..fdd1c7b48 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -3,15 +3,18 @@ #include "DeviceIoWorker.hpp" #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" +#include "SingleInstanceGuard.hpp" #include "SmartSessionController.hpp" #include "StudioDeviceController.hpp" #include #include #include +#include #include #include #include +#include #include #include #include @@ -295,12 +298,40 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { !SmartSessionController::firmwareOwnershipValid( SmartSessionController::PortOwner::none, false); + const bool s6RecoveryPolicy = + SmartSessionController::recoveryIdentityMatches( + QStringLiteral("A1B2C3D4E5F6"), QStringLiteral("a1b2c3d4e5f6")) && + !SmartSessionController::recoveryIdentityMatches( + QStringLiteral("A1B2C3D4E5F6"), QStringLiteral("001122334455")) && + !SmartSessionController::recoveryIdentityMatches(QString{}, QStringLiteral("A1B2C3D4E5F6")) && + StudioDeviceController::controlHealthProbeIntervalMs() == 2000 && + StudioDeviceController::controlHealthMaxMisses() == 2; + + const QString lockTestPath = QDir(QDir::tempPath()).filePath( + QStringLiteral("arstack-studio-lock-regression-%1.lock") + .arg(static_cast(QCoreApplication::applicationPid()))); + QFile::remove(lockTestPath); + bool secondInstanceBlocked = false; + { + SingleInstanceGuard first{lockTestPath}; + SingleInstanceGuard second{lockTestPath}; + secondInstanceBlocked = first.tryAcquire() && !second.tryAcquire(); + } + bool lockReacquiredAfterRelease = false; + { + SingleInstanceGuard third{lockTestPath}; + lockReacquiredAfterRelease = third.tryAcquire(); + } + QFile::remove(lockTestPath); + const bool singleInstancePolicy = secondInstanceBlocked && lockReacquiredAfterRelease; + if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || !rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy || - !workerPolicyAligned || !generationPolicy || !portOwnershipPolicy) { + !workerPolicyAligned || !generationPolicy || !portOwnershipPolicy || + !s6RecoveryPolicy || !singleInstancePolicy) { qCritical().noquote() - << "S1/S2/S3/S4/S5 control-plane contract: FAIL" + << "S1/S2/S3/S4/S5/S6 control-plane contract: FAIL" << "current=" << currentAccepted << "legacy=" << legacyRejectedAsCurrent << "protocol-legacy=" << protocolLegacyParsed @@ -312,7 +343,9 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { << "bounded-profile-sync=" << boundedProfileSyncPolicy << "worker-policy-aligned=" << workerPolicyAligned << "generation-policy=" << generationPolicy - << "port-owner-policy=" << portOwnershipPolicy; + << "port-owner-policy=" << portOwnershipPolicy + << "s6-recovery-health=" << s6RecoveryPolicy + << "single-instance=" << singleInstancePolicy; return 11; } @@ -335,9 +368,11 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { const bool startsIdle = device.identificationState() == DeviceController::IdentificationState::Idle && device.identifyAttempts() == 0 && - device.sessionGeneration() == 1; + device.sessionGeneration() == 1 && + !device.controlResponsive() && + device.missedHealthReplies() == 0; if (!startsIdle) { - qCritical().noquote() << "S5 generation-aware identification state: FAIL"; + qCritical().noquote() << "S6 generation/health-aware identification state: FAIL"; return 12; } if (device.start()) { @@ -349,7 +384,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { return 6; } qInfo().noquote() - << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded profile sync + S4 threaded DeviceIoWorker + S5 generation/PortOwner/FirmwareWorker boundaries + unverified START/DEPLOY fail closed"; + << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded profile sync + S4 threaded DeviceIoWorker + S5 generation/PortOwner/FirmwareWorker + S6 device-id recovery/health/single-instance boundaries + unverified START/DEPLOY fail closed"; return 0; } } // namespace @@ -373,6 +408,16 @@ int main(int argc, char* argv[]) { QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); + // S6 ownership starts before any firmware process, QML object, or serial + // worker exists. A second Studio process therefore cannot race for the + // injector COM handle or bypass the supervisor's in-process PortOwner. + SingleInstanceGuard instanceGuard; + if (!instanceGuard.tryAcquire()) { + qCritical().noquote() + << "ARStack Studio is already running. Close the existing instance before opening another."; + return SingleInstanceGuard::contentionExitCode(); + } + FirmwareManager firmwareService; QObject::connect( &app, @@ -440,4 +485,4 @@ int main(int argc, char* argv[]) { qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter"; } return result; -} +} \ No newline at end of file From 4d1584397c690d311102c5bb279abef58081db35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 19:09:36 +0700 Subject: [PATCH 213/272] S6: register single-instance guard source --- apps/arstack_studio/CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 65221db0d..e1cda0854 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -26,6 +26,7 @@ qt_add_executable(arstack_studio src/DeviceController.cpp src/DeviceController.hpp src/StudioDeviceController.hpp + src/SingleInstanceGuard.hpp src/FirmwareWorker.cpp src/FirmwareWorker.hpp src/FirmwareManager.cpp @@ -104,4 +105,4 @@ target_compile_definitions(arstack_studio PRIVATE if(WIN32) set_target_properties(arstack_studio PROPERTIES WIN32_EXECUTABLE TRUE) -endif() +endif() \ No newline at end of file From 4378aec85360b801c5569e114e0daf34ec8261ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 19:11:38 +0700 Subject: [PATCH 214/272] S6: expose known-device recovery gate --- .../src/SmartSessionController.hpp | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index f7c1d8f71..22c859517 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -26,6 +26,7 @@ class SmartSessionController : public QObject { Q_PROPERTY(bool profileSyncRetryAvailable READ profileSyncRetryAvailable NOTIFY stateChanged) Q_PROPERTY(bool updatingFirmware READ updatingFirmware NOTIFY stateChanged) Q_PROPERTY(bool updateNeedsBootloaderHelp READ updateNeedsBootloaderHelp NOTIFY stateChanged) + Q_PROPERTY(bool recoveryPending READ recoveryPending NOTIFY stateChanged) Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged) Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged) Q_PROPERTY(QString firmwareSetupPort READ firmwareSetupPort NOTIFY stateChanged) @@ -56,6 +57,7 @@ class SmartSessionController : public QObject { [[nodiscard]] bool profileSyncRetryAvailable() const noexcept; [[nodiscard]] bool updatingFirmware() const noexcept; [[nodiscard]] bool updateNeedsBootloaderHelp() const noexcept; + [[nodiscard]] bool recoveryPending() const noexcept { return recoveryPending_; } [[nodiscard]] int firmwareProgress() const noexcept; [[nodiscard]] QString updateStatus() const; [[nodiscard]] QString firmwareSetupPort() const; @@ -241,12 +243,18 @@ class SmartSessionController : public QObject { }); } + void setRecoveryPending(const bool pending) { + if (owner_ == nullptr || owner_->recoveryPending_ == pending) return; + owner_->recoveryPending_ = pending; + emit owner_->stateChanged(); + } + void handleVerificationChange() { if (owner_ == nullptr || device_ == nullptr) return; if (device_->deviceVerified()) { const QString observed = device_->deviceId().trimmed(); - if (recoveryPending_ && !expectedRecoveryDeviceId_.isEmpty() && + if (owner_->recoveryPending_ && !expectedRecoveryDeviceId_.isEmpty() && !SmartSessionController::recoveryIdentityMatches( expectedRecoveryDeviceId_, observed)) { owner_->blankBoardDetected_ = false; @@ -265,14 +273,14 @@ class SmartSessionController : public QObject { lastVerifiedDeviceId_ = observed; expectedRecoveryDeviceId_ = observed; } - recoveryPending_ = false; + setRecoveryPending(false); wasVerified_ = true; return; } if (wasVerified_ && !owner_->updateRequested_) { expectedRecoveryDeviceId_ = lastVerifiedDeviceId_; - recoveryPending_ = !expectedRecoveryDeviceId_.isEmpty(); + setRecoveryPending(!expectedRecoveryDeviceId_.isEmpty()); owner_->blankBoardDetected_ = false; owner_->blankBoardPort_.clear(); owner_->resetProfileSync(true); @@ -281,7 +289,7 @@ class SmartSessionController : public QObject { } void handleIdentificationChange() { - if (owner_ == nullptr || device_ == nullptr || !recoveryPending_ || + if (owner_ == nullptr || device_ == nullptr || !owner_->recoveryPending_ || device_->identificationState() != DeviceController::IdentificationState::Unidentified) { return; } @@ -304,7 +312,6 @@ class SmartSessionController : public QObject { QString lastVerifiedDeviceId_; QString expectedRecoveryDeviceId_; bool wasVerified_{false}; - bool recoveryPending_{false}; }; void reconnectDeviceSignals(); @@ -355,6 +362,7 @@ class SmartSessionController : public QObject { bool updateRequested_{false}; bool blankBoardDetected_{false}; bool setupError_{false}; + bool recoveryPending_{false}; int updateReconnectAttempts_{0}; int profileSyncAttempts_{0}; PortOwner portOwner_{PortOwner::none}; From 62a9c5af86a0d5bef914e8cf1f308aec614a2682 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 19:12:29 +0700 Subject: [PATCH 215/272] S6: suppress install offer during known-device recovery --- apps/arstack_studio/src/SmartSessionController.hpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 22c859517..7d2b3bf40 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -21,7 +21,7 @@ class SmartSessionController : public QObject { Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) - Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallRequired NOTIFY stateChanged) + Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged) Q_PROPERTY(bool firmwareRetryAvailable READ firmwareRetryAvailable NOTIFY stateChanged) Q_PROPERTY(bool profileSyncRetryAvailable READ profileSyncRetryAvailable NOTIFY stateChanged) Q_PROPERTY(bool updatingFirmware READ updatingFirmware NOTIFY stateChanged) @@ -53,6 +53,9 @@ class SmartSessionController : public QObject { [[nodiscard]] bool startReady() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; [[nodiscard]] bool firmwareInstallRequired() const noexcept; + [[nodiscard]] bool firmwareInstallVisible() const noexcept { + return !recoveryPending_ && firmwareInstallRequired(); + } [[nodiscard]] bool firmwareRetryAvailable() const noexcept; [[nodiscard]] bool profileSyncRetryAvailable() const noexcept; [[nodiscard]] bool updatingFirmware() const noexcept; From 0b8083bd7ad4663f9632bfdc358956fbb5ec1048 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:18:14 +0700 Subject: [PATCH 216/272] S7: centralize operator intent authority in session supervisor --- .../src/SmartSessionController.hpp | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 7d2b3bf40..e8851372a 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -20,6 +20,9 @@ class SmartSessionController : public QObject { Q_PROPERTY(QString state READ state NOTIFY stateChanged) Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) + Q_PROPERTY(bool canDeployProfile READ canDeployProfile NOTIFY stateChanged) + Q_PROPERTY(bool liveControlReady READ liveControlReady NOTIFY stateChanged) + Q_PROPERTY(bool engineeringEditable READ engineeringEditable NOTIFY stateChanged) Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged) Q_PROPERTY(bool firmwareRetryAvailable READ firmwareRetryAvailable NOTIFY stateChanged) @@ -51,6 +54,9 @@ class SmartSessionController : public QObject { [[nodiscard]] QString state() const; [[nodiscard]] QString statusText() const; [[nodiscard]] bool startReady() const noexcept; + [[nodiscard]] bool canDeployProfile() const noexcept; + [[nodiscard]] bool liveControlReady() const noexcept; + [[nodiscard]] bool engineeringEditable() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; [[nodiscard]] bool firmwareInstallRequired() const noexcept; [[nodiscard]] bool firmwareInstallVisible() const noexcept { @@ -114,6 +120,39 @@ class SmartSessionController : public QObject { Q_INVOKABLE void start(); Q_INVOKABLE void reconcile(); + + // S7: QML submits operator intent only. The supervisor owns every gate that + // can mutate device connection, output, live values, profile state or PTP. + Q_INVOKABLE bool requestStart(); + Q_INVOKABLE bool requestStop(); + Q_INVOKABLE bool requestConnect(); + Q_INVOKABLE void requestRefreshPorts(); + Q_INVOKABLE bool requestConnectPort(const QString& portName); + Q_INVOKABLE bool requestDisconnect(); + Q_INVOKABLE bool requestProfileSync(); + Q_INVOKABLE bool requestSetFrequency(double hz); + Q_INVOKABLE bool requestSetSignal( + const QString& signalId, + double magnitude, + double phaseDegrees, + quint32 quality, + double currentCountsPerAmp, + double voltageCountsPerVolt); + Q_INVOKABLE bool requestSetQuality(const QString& signalId, quint32 quality); + Q_INVOKABLE bool requestSetCtSaturation( + bool enabled, + double dcOffsetPercent, + double harmonicPercent, + int harmonicOrder, + double clipPercent); + Q_INVOKABLE bool requestZero(); + Q_INVOKABLE bool requestPtpRefresh(); + Q_INVOKABLE bool requestPtpRole(const QString& role); + Q_INVOKABLE bool requestSmpSynch(const QString& mode); + Q_INVOKABLE bool requestConfigurePtp(const QVariantMap& profile); + Q_INVOKABLE bool requestStartPtp(); + Q_INVOKABLE bool requestStopPtp(); + Q_INVOKABLE bool beginFirmwareUpdate(); Q_INVOKABLE bool beginFirmwareInstall(); Q_INVOKABLE bool retryFirmwareUpdate(); @@ -333,6 +372,7 @@ class SmartSessionController : public QObject { quint64 advanceSessionGeneration(); void setPortOwner(PortOwner owner); bool firmwareIsCurrent() const; + bool deviceControlAvailable() const noexcept; void resetProfileSync(bool requireSync); bool beginProfileSync(const QVariantMap& profile); void handleProfileStateChanged(); From 7222968e16d1dbb9e38d37fcd2d6011522ff911a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:20:01 +0700 Subject: [PATCH 217/272] S7: route device writes through supervisor intents --- .../src/SmartSessionController.cpp | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 955020f65..cf182032c 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -104,6 +104,22 @@ QObject* SmartSessionController::firmware() const noexcept { return firmware_; } QString SmartSessionController::state() const { return state_; } QString SmartSessionController::statusText() const { return statusText_; } bool SmartSessionController::startReady() const noexcept { return startReady_; } +bool SmartSessionController::canDeployProfile() const noexcept { + if (!deviceControlAvailable() || profiles_ == nullptr || device_->running() || device_->profileDeploying()) { + return false; + } + const QVariantMap profile = profiles_->selectedProfile(); + return profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && + profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready"); +} +bool SmartSessionController::liveControlReady() const noexcept { + return deviceControlAvailable() && !device_->profileDeploying() && + (startReady_ || device_->running()); +} +bool SmartSessionController::engineeringEditable() const noexcept { + return device_ != nullptr && !device_->running() && !updateRequested_ && + portOwner_ != PortOwner::firmwareTool && (firmware_ == nullptr || !firmware_->busy()); +} bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } bool SmartSessionController::firmwareInstallRequired() const noexcept { return blankBoardDetected_ && !updateRequested_; @@ -235,6 +251,143 @@ void SmartSessionController::start() { }); } +bool SmartSessionController::requestStart() { + if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; + return device_->start(); +} + +bool SmartSessionController::requestStop() { + if (device_ == nullptr) return false; + if (!device_->running()) return true; + if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; + return device_->stop(); +} + +bool SmartSessionController::requestConnect() { + if (!started_ || device_ == nullptr || updateRequested_ || + portOwner_ == PortOwner::firmwareTool || device_->discovering()) { + return false; + } + if (device_->deviceVerified()) return true; + if (device_->connected()) return false; + if (firmware_ != nullptr && firmware_->busy()) return false; + + clearBlankBoardContext(); + const bool accepted = startDeviceDiscovery(); + reconcile(); + return accepted; +} + +void SmartSessionController::requestRefreshPorts() { + if (device_ == nullptr || updateRequested_ || portOwner_ == PortOwner::firmwareTool || + (firmware_ != nullptr && firmware_->busy())) { + return; + } + device_->refreshPorts(); +} + +bool SmartSessionController::requestConnectPort(const QString& portName) { + const QString requested = portName.trimmed(); + if (!started_ || device_ == nullptr || requested.isEmpty() || updateRequested_ || + portOwner_ == PortOwner::firmwareTool || device_->connected() || device_->discovering() || + (firmware_ != nullptr && firmware_->busy())) { + return false; + } + + clearBlankBoardContext(); + advanceSessionGeneration(); + setPortOwner(PortOwner::deviceSession); + const bool accepted = device_->connectPort(requested); + reconcile(); + return accepted; +} + +bool SmartSessionController::requestDisconnect() { + if (device_ == nullptr || updateRequested_ || portOwner_ != PortOwner::deviceSession) return false; + if (!device_->connected()) return true; + if (device_->running()) return false; + device_->disconnectPort(); + return true; +} + +bool SmartSessionController::requestProfileSync() { + if (!canDeployProfile()) return false; + resetProfileSync(true); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + return true; +} + +bool SmartSessionController::requestSetFrequency(const double hz) { + return liveControlReady() && device_ != nullptr && device_->setFrequency(hz); +} + +bool SmartSessionController::requestSetSignal( + const QString& signalId, + const double magnitude, + const double phaseDegrees, + const quint32 quality, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + return liveControlReady() && device_ != nullptr && + device_->setSignal( + signalId, + magnitude, + phaseDegrees, + quality, + currentCountsPerAmp, + voltageCountsPerVolt); +} + +bool SmartSessionController::requestSetQuality(const QString& signalId, const quint32 quality) { + return liveControlReady() && device_ != nullptr && device_->setQuality(signalId, quality); +} + +bool SmartSessionController::requestSetCtSaturation( + const bool enabled, + const double dcOffsetPercent, + const double harmonicPercent, + const int harmonicOrder, + const double clipPercent) { + return liveControlReady() && device_ != nullptr && + device_->setCtSaturation( + enabled, + dcOffsetPercent, + harmonicPercent, + harmonicOrder, + clipPercent); +} + +bool SmartSessionController::requestZero() { + return liveControlReady() && device_ != nullptr && device_->zero(); +} + +bool SmartSessionController::requestPtpRefresh() { + if (!deviceControlAvailable() || device_ == nullptr) return false; + const bool ptp = device_->sendPtpShow(); + const bool sync = device_->sendSmpSynchShow(); + return ptp && sync; +} + +bool SmartSessionController::requestPtpRole(const QString& role) { + return deviceControlAvailable() && device_ != nullptr && device_->setPtpRole(role); +} + +bool SmartSessionController::requestSmpSynch(const QString& mode) { + return deviceControlAvailable() && device_ != nullptr && device_->setSmpSynchPolicy(mode); +} + +bool SmartSessionController::requestConfigurePtp(const QVariantMap& profile) { + return deviceControlAvailable() && device_ != nullptr && device_->configurePtp(profile); +} + +bool SmartSessionController::requestStartPtp() { + return deviceControlAvailable() && device_ != nullptr && device_->startPtp(); +} + +bool SmartSessionController::requestStopPtp() { + return deviceControlAvailable() && device_ != nullptr && device_->stopPtp(); +} + bool SmartSessionController::beginFirmwareUpdate() { if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || !firmware_->bundleReady() || firmware_->busy()) { @@ -748,6 +901,12 @@ bool SmartSessionController::firmwareIsCurrent() const { device_->deviceIdentity(), expectedFirmwareVersion()); } +bool SmartSessionController::deviceControlAvailable() const noexcept { + return device_ != nullptr && device_->deviceVerified() && firmwareIsCurrent() && + portOwner_ == PortOwner::deviceSession && !updateRequested_ && + (firmware_ == nullptr || !firmware_->busy()); +} + void SmartSessionController::reconcile() { if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) { setPresentation( From b597a4a70a84be5b6c3821ad3dafd602247f7e6c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:22:25 +0700 Subject: [PATCH 218/272] S7: route workflow operator actions through session supervisor --- apps/arstack_studio/qml/WorkflowBar.qml | 41 +++++++++++++------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 548c609ee..b99626fb6 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -25,6 +25,7 @@ SurfacePanel { readonly property bool firmwareGateActive: smartSession.firmwareInstallRequired || smartSession.firmwareUpdateRequired || smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp + readonly property bool injectionRunning: smartSession.state === "RUNNING" Settings { id: operatorSettings @@ -182,7 +183,7 @@ SurfacePanel { } function startReason() { - if (device.running) return "SMV output is already running." + if (ribbon.injectionRunning) return "SMV output is already running." if (smartSession.firmwareInstallRequired) return "Install ARStack firmware before starting injection." if (smartSession.firmwareUpdateRequired) return "Update firmware before starting injection." if (smartSession.updatingFirmware) return "Firmware installation is in progress." @@ -195,7 +196,7 @@ SurfacePanel { } function requestStart() { - if (device.running) return + if (ribbon.injectionRunning) return if (smartSession.firmwareInstallRequired) { ribbon.installPromptDeferred = false installDialog.open() @@ -221,21 +222,23 @@ SurfacePanel { controller.showMessage(ribbon.startReason(), false) return } - device.start() + if (!smartSession.requestStart()) + controller.showMessage(smartSession.statusText, true) } function requestStop() { - if (device.running) device.stop() + if (ribbon.injectionRunning && !smartSession.requestStop()) + controller.showMessage("Studio could not stop the current device session.", true) } function openEngineeringDialog() { - if (!device.running) engineeringFileDialog.open() - else controller.showMessage("Stop injection before changing the engineering configuration.", true) + if (smartSession.engineeringEditable) engineeringFileDialog.open() + else controller.showMessage("Stop injection or finish the current device operation before changing the engineering configuration.", true) } function loadEngineeringFile(url) { - if (device.running) { - controller.showMessage("Stop injection before changing the engineering configuration.", true) + if (!smartSession.engineeringEditable) { + controller.showMessage("Stop injection or finish the current device operation before changing the engineering configuration.", true) return false } if (!profiles.loadFile(url)) { @@ -250,8 +253,8 @@ SurfacePanel { } function useBuiltInProfile() { - if (device.running) { - controller.showMessage("Stop injection before changing the engineering configuration.", true) + if (!smartSession.engineeringEditable) { + controller.showMessage("Stop injection or finish the current device operation before changing the engineering configuration.", true) return } if (profiles.loadReferenceTemplate()) { @@ -537,14 +540,14 @@ SurfacePanel { Menu { id: fileMenu y: parent.height - MenuItem { text: "Open Engineering File…"; enabled: !device.running; onTriggered: ribbon.openEngineeringDialog() } + MenuItem { text: "Open Engineering File…"; enabled: smartSession.engineeringEditable; onTriggered: ribbon.openEngineeringDialog() } MenuItem { text: "Reopen Last Configuration" - enabled: !device.running && operatorSettings.lastEngineeringUrl.length > 0 + enabled: smartSession.engineeringEditable && operatorSettings.lastEngineeringUrl.length > 0 onTriggered: ribbon.loadEngineeringFile(operatorSettings.lastEngineeringUrl) } MenuSeparator {} - MenuItem { text: "Use Built-in 4I+4V Profile"; enabled: !device.running; onTriggered: ribbon.useBuiltInProfile() } + MenuItem { text: "Use Built-in 4I+4V Profile"; enabled: smartSession.engineeringEditable; onTriggered: ribbon.useBuiltInProfile() } MenuSeparator {} MenuItem { text: "Exit"; onTriggered: Qt.quit() } } @@ -557,8 +560,8 @@ SurfacePanel { Menu { id: injectionMenu y: parent.height - MenuItem { text: "Start Injection F5"; enabled: !device.running; onTriggered: ribbon.requestStart() } - MenuItem { text: "Stop Injection F6"; enabled: device.running; onTriggered: ribbon.requestStop() } + MenuItem { text: "Start Injection F5"; enabled: !ribbon.injectionRunning; onTriggered: ribbon.requestStart() } + MenuItem { text: "Stop Injection F6"; enabled: ribbon.injectionRunning; onTriggered: ribbon.requestStop() } MenuSeparator {} MenuItem { text: "Balanced 3-Phase"; onTriggered: controller.balanced() } MenuItem { text: "Zero All"; onTriggered: controller.zeroAll() } @@ -690,16 +693,16 @@ SurfacePanel { text: "Start" iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") tone: smartSession.startReady ? "success" : "neutral" - enabled: !device.running && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp + enabled: !ribbon.injectionRunning && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp toolTipText: ribbon.startReason() onClicked: ribbon.requestStart() } RunButton { text: "Stop" iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") - tone: device.running ? "danger" : "neutral" - enabled: device.running && !smartSession.updatingFirmware - toolTipText: device.running ? "Stop Sampled Values output" : "Injection is not running" + tone: ribbon.injectionRunning ? "danger" : "neutral" + enabled: ribbon.injectionRunning && !smartSession.updatingFirmware + toolTipText: ribbon.injectionRunning ? "Stop Sampled Values output" : "Injection is not running" onClicked: ribbon.requestStop() } } From cebdf1734a425811ead4884bf0b96b99efab91eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:22:54 +0700 Subject: [PATCH 219/272] S7: route manual device recovery through supervisor --- apps/arstack_studio/qml/DeviceExpertPanel.qml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index b09b8446c..0470ddf30 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -6,6 +6,7 @@ import QtQuick.Layouts SurfacePanel { id: panel property var device + property var session property string uiFont: "Inter" property string monoFont: "Inter" @@ -56,18 +57,28 @@ SurfacePanel { id: recoveryPort Layout.preferredWidth: 170 model: panel.device.ports - enabled: !panel.device.connected + enabled: panel.session && panel.session.engineeringEditable && !panel.device.connected font.family: panel.uiFont font.pixelSize: 9 - onPressedChanged: if (pressed) panel.device.refreshPorts() + onPressedChanged: if (pressed && panel.session) panel.session.requestRefreshPorts() } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Refresh"; onClicked: panel.device.refreshPorts() } CalmButton { theme: panel.theme uiFont: panel.uiFont + text: "Refresh" + enabled: panel.session && !panel.session.updatingFirmware + onClicked: panel.session.requestRefreshPorts() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + enabled: panel.session && !panel.session.updatingFirmware && + (!panel.device.connected || panel.session.engineeringEditable) tone: panel.device.connected ? "danger" : "accent" text: panel.device.connected ? "Disconnect" : "Verify selected port" - onClicked: panel.device.connected ? panel.device.disconnectPort() : panel.device.connectPort(recoveryPort.currentText) + onClicked: panel.device.connected + ? panel.session.requestDisconnect() + : panel.session.requestConnectPort(recoveryPort.currentText) } Item { Layout.fillWidth: true } } From d813e26c7f8b3734665032f01e2fb2e6a78d115a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:24:08 +0700 Subject: [PATCH 220/272] S7: expose typed PTP and smpSynch presentation state --- apps/arstack_studio/src/DeviceController.hpp | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index a7befdd7e..641c9491e 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -62,7 +62,30 @@ class DeviceController : public QObject { Q_PROPERTY(QString ptpVlan READ ptpVlan NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpAnnounceSent READ ptpAnnounceSent NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpSyncSent READ ptpSyncSent NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpFollowUpSent READ ptpFollowUpSent NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpPdelayFrames READ ptpPdelayFrames NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTxFailures READ ptpTxFailures NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRole READ ptpRole NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpDiscipline READ ptpDiscipline NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpSource READ ptpSource NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpOffsetNs READ ptpOffsetNs NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpPathDelayNs READ ptpPathDelayNs NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpJitterNs READ ptpJitterNs NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpFrequencyPpb READ ptpFrequencyPpb NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpGlobalTraceable READ ptpGlobalTraceable NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpMeasuredSmpSynch READ ptpMeasuredSmpSynch NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRxAnnounce READ ptpRxAnnounce NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRxSync READ ptpRxSync NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRxFollowUp READ ptpRxFollowUp NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRxPdelay READ ptpRxPdelay NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpPdelayRequests READ ptpPdelayRequests NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpAccepted READ ptpAccepted NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRejected READ ptpRejected NOTIFY ptpStateChanged) + Q_PROPERTY(QString smpSynchMode READ smpSynchMode NOTIFY ptpStateChanged) + Q_PROPERTY(QString smpSynchValue READ smpSynchValue NOTIFY ptpStateChanged) + Q_PROPERTY(QString smpSynchSource READ smpSynchSource NOTIFY ptpStateChanged) + Q_PROPERTY(bool smpSynchSimulated READ smpSynchSimulated NOTIFY ptpStateChanged) + Q_PROPERTY(bool smpSynchMeasured READ smpSynchMeasured NOTIFY ptpStateChanged) public: enum class IdentificationState { @@ -111,7 +134,30 @@ class DeviceController : public QObject { [[nodiscard]] QString ptpVlan() const; [[nodiscard]] QString ptpAnnounceSent() const; [[nodiscard]] QString ptpSyncSent() const; + [[nodiscard]] QString ptpFollowUpSent() const { return ptpFollowUpSent_; } + [[nodiscard]] QString ptpPdelayFrames() const { return ptpPdelayFrames_; } [[nodiscard]] QString ptpTxFailures() const; + [[nodiscard]] QString ptpRole() const { return ptpRole_; } + [[nodiscard]] QString ptpDiscipline() const { return ptpDiscipline_; } + [[nodiscard]] QString ptpSource() const { return ptpSource_; } + [[nodiscard]] QString ptpOffsetNs() const { return ptpOffsetNs_; } + [[nodiscard]] QString ptpPathDelayNs() const { return ptpPathDelayNs_; } + [[nodiscard]] QString ptpJitterNs() const { return ptpJitterNs_; } + [[nodiscard]] QString ptpFrequencyPpb() const { return ptpFrequencyPpb_; } + [[nodiscard]] bool ptpGlobalTraceable() const noexcept { return ptpGlobalTraceable_; } + [[nodiscard]] QString ptpMeasuredSmpSynch() const { return ptpMeasuredSmpSynch_; } + [[nodiscard]] QString ptpRxAnnounce() const { return ptpRxAnnounce_; } + [[nodiscard]] QString ptpRxSync() const { return ptpRxSync_; } + [[nodiscard]] QString ptpRxFollowUp() const { return ptpRxFollowUp_; } + [[nodiscard]] QString ptpRxPdelay() const { return ptpRxPdelay_; } + [[nodiscard]] QString ptpPdelayRequests() const { return ptpPdelayRequests_; } + [[nodiscard]] QString ptpAccepted() const { return ptpAccepted_; } + [[nodiscard]] QString ptpRejected() const { return ptpRejected_; } + [[nodiscard]] QString smpSynchMode() const { return smpSynchMode_; } + [[nodiscard]] QString smpSynchValue() const { return smpSynchValue_; } + [[nodiscard]] QString smpSynchSource() const { return smpSynchSource_; } + [[nodiscard]] bool smpSynchSimulated() const noexcept { return smpSynchSimulated_; } + [[nodiscard]] bool smpSynchMeasured() const noexcept { return smpSynchMeasured_; } [[nodiscard]] quint64 sessionGeneration() const noexcept { return sessionGeneration_; } [[nodiscard]] bool ioWorkerReady() const noexcept { return ioWorkerReady_; } @@ -240,6 +286,7 @@ class DeviceController : public QObject { void appendLog(const QString& direction, const QString& line); void processLine(const QString& rawLine); void resetTelemetry(); + void resetPtpState(); static QString cleanLine(const QString& rawLine); static QString utf8Hex(const QString& text); static QString compactMac(const QString& text); @@ -282,5 +329,28 @@ class DeviceController : public QObject { QString ptpVlan_{QStringLiteral("-")}; QString ptpAnnounceSent_{QStringLiteral("-")}; QString ptpSyncSent_{QStringLiteral("-")}; + QString ptpFollowUpSent_{QStringLiteral("-")}; + QString ptpPdelayFrames_{QStringLiteral("-")}; QString ptpTxFailures_{QStringLiteral("-")}; + QString ptpRole_{QStringLiteral("SOURCE")}; + QString ptpDiscipline_{QStringLiteral("UNLOCKED")}; + QString ptpSource_{QStringLiteral("NONE")}; + QString ptpOffsetNs_{QStringLiteral("NA")}; + QString ptpPathDelayNs_{QStringLiteral("NA")}; + QString ptpJitterNs_{QStringLiteral("NA")}; + QString ptpFrequencyPpb_{QStringLiteral("0")}; + QString ptpMeasuredSmpSynch_{QStringLiteral("NA")}; + QString ptpRxAnnounce_{QStringLiteral("0")}; + QString ptpRxSync_{QStringLiteral("0")}; + QString ptpRxFollowUp_{QStringLiteral("0")}; + QString ptpRxPdelay_{QStringLiteral("0")}; + QString ptpPdelayRequests_{QStringLiteral("0")}; + QString ptpAccepted_{QStringLiteral("0")}; + QString ptpRejected_{QStringLiteral("0")}; + QString smpSynchMode_{QStringLiteral("AUTO")}; + QString smpSynchValue_{QStringLiteral("0")}; + QString smpSynchSource_{QStringLiteral("SAFE_DEFAULT")}; + bool ptpGlobalTraceable_{false}; + bool smpSynchSimulated_{false}; + bool smpSynchMeasured_{false}; }; From 690cf2b67bed31c92578911f5b5c50fa937d2826 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:26:16 +0700 Subject: [PATCH 221/272] S7: parse timing state once in DeviceController --- apps/arstack_studio/src/DeviceController.cpp | 118 +++++++++++++++++-- 1 file changed, 110 insertions(+), 8 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index d155820c4..ae72cf0e9 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -41,6 +41,20 @@ const QRegularExpression kPtpStatusExpression{ const QRegularExpression kPtpConfigExpression{ QStringLiteral("PTP config domain=(\\d+) transportSpecific=0x([0-9A-Fa-f]+) VLAN=(\\S+) Announce=(\\d+) ms Sync=(\\d+) ms Pdelay=(ON|OFF)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpRoleExpression{ + QStringLiteral("PTPROLE role=(SOURCE|RECEIVER|MONITOR) value=(\\d+) running=([01])"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpMeasuredStateExpression{ + QStringLiteral( + "PTP2 role=(SOURCE|RECEIVER|MONITOR) discipline=(UNLOCKED|ACQUIRING|LOCKED|HOLDOVER|FAULT) " + "source=(\\S+) offset=(NA|-?\\d+) path=(NA|-?\\d+) jitter=(NA|-?\\d+) freq=(-?\\d+) " + "global=([01]) measured=(NA|[012]) rxAnnounce=(\\d+) rxSync=(\\d+) rxFollowUp=(\\d+) " + "rxPdelay=(\\d+) pdelayReq=(\\d+) accepted=(\\d+) rejected=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kSmpSynchExpression{ + QStringLiteral( + "SMPSYNCH mode=([A-Z0-9_]+) advertised=([0-2]) source=([A-Z0-9_]+) simulated=([01]) measured=([01])"), + QRegularExpression::CaseInsensitiveOption}; [[nodiscard]] bool finitePositive(const double value) noexcept { return std::isfinite(value) && value > 0.0; @@ -430,9 +444,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto running_ = false; profileArmed_ = false; profileDeploying_ = false; - ptpAvailable_ = false; - ptpRunning_ = false; - ptpStatus_ = QStringLiteral("Waiting for device"); + resetPtpState(); resetTelemetry(); if (!lastError_.isEmpty()) { @@ -462,9 +474,7 @@ void DeviceController::handlePortClosed(const QString& portName) { identifyAttempts_ = 0; setIdentificationState(IdentificationState::Idle); discovering_ = false; - ptpAvailable_ = false; - ptpRunning_ = false; - ptpStatus_ = QStringLiteral("Waiting for device"); + resetPtpState(); resetTelemetry(); emit runningChanged(); @@ -833,6 +843,8 @@ void DeviceController::processLine(const QString& rawLine) { ptpRunning_ = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; ptpAnnounceSent_ = match.captured(2); ptpSyncSent_ = match.captured(3); + ptpFollowUpSent_ = match.captured(4); + ptpPdelayFrames_ = match.captured(5); ptpTxFailures_ = match.captured(6); ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : QStringLiteral("Lab TX stopped"); emit ptpStateChanged(); @@ -847,9 +859,64 @@ void DeviceController::processLine(const QString& rawLine) { emit ptpStateChanged(); } + match = kPtpRoleExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpRole_ = match.captured(1).toUpper(); + ptpRunning_ = match.captured(3) == QStringLiteral("1"); + ptpDiscipline_ = QStringLiteral("UNLOCKED"); + ptpSource_ = QStringLiteral("NONE"); + ptpOffsetNs_ = QStringLiteral("NA"); + ptpPathDelayNs_ = QStringLiteral("NA"); + ptpJitterNs_ = QStringLiteral("NA"); + ptpFrequencyPpb_ = QStringLiteral("0"); + ptpGlobalTraceable_ = false; + ptpMeasuredSmpSynch_ = QStringLiteral("NA"); + ptpRxAnnounce_ = QStringLiteral("0"); + ptpRxSync_ = QStringLiteral("0"); + ptpRxFollowUp_ = QStringLiteral("0"); + ptpRxPdelay_ = QStringLiteral("0"); + ptpPdelayRequests_ = QStringLiteral("0"); + ptpAccepted_ = QStringLiteral("0"); + ptpRejected_ = QStringLiteral("0"); + ptpStatus_ = ptpRunning_ ? QStringLiteral("Timing active") : QStringLiteral("Timing stopped"); + emit ptpStateChanged(); + } + + match = kPtpMeasuredStateExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpRole_ = match.captured(1).toUpper(); + ptpDiscipline_ = match.captured(2).toUpper(); + ptpSource_ = match.captured(3); + ptpOffsetNs_ = match.captured(4); + ptpPathDelayNs_ = match.captured(5); + ptpJitterNs_ = match.captured(6); + ptpFrequencyPpb_ = match.captured(7); + ptpGlobalTraceable_ = match.captured(8) == QStringLiteral("1"); + ptpMeasuredSmpSynch_ = match.captured(9).toUpper(); + ptpRxAnnounce_ = match.captured(10); + ptpRxSync_ = match.captured(11); + ptpRxFollowUp_ = match.captured(12); + ptpRxPdelay_ = match.captured(13); + ptpPdelayRequests_ = match.captured(14); + ptpAccepted_ = match.captured(15); + ptpRejected_ = match.captured(16); + emit ptpStateChanged(); + } + + match = kSmpSynchExpression.match(line); + if (match.hasMatch()) { + smpSynchMode_ = match.captured(1).toUpper(); + smpSynchValue_ = match.captured(2); + smpSynchSource_ = match.captured(3).toUpper(); + smpSynchSimulated_ = match.captured(4) == QStringLiteral("1"); + smpSynchMeasured_ = match.captured(5) == QStringLiteral("1"); + emit ptpStateChanged(); + } + if (line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive)) { - ptpAvailable_ = false; - ptpRunning_ = false; + resetPtpState(); ptpStatus_ = QStringLiteral("Not enabled in firmware"); emit ptpStateChanged(); } @@ -875,6 +942,41 @@ void DeviceController::resetTelemetry() { emit telemetryChanged(); } +void DeviceController::resetPtpState() { + ptpAvailable_ = false; + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("Waiting for device"); + ptpDomain_ = QStringLiteral("-"); + ptpTransportSpecific_ = QStringLiteral("-"); + ptpVlan_ = QStringLiteral("-"); + ptpAnnounceSent_ = QStringLiteral("-"); + ptpSyncSent_ = QStringLiteral("-"); + ptpFollowUpSent_ = QStringLiteral("-"); + ptpPdelayFrames_ = QStringLiteral("-"); + ptpTxFailures_ = QStringLiteral("-"); + ptpRole_ = QStringLiteral("SOURCE"); + ptpDiscipline_ = QStringLiteral("UNLOCKED"); + ptpSource_ = QStringLiteral("NONE"); + ptpOffsetNs_ = QStringLiteral("NA"); + ptpPathDelayNs_ = QStringLiteral("NA"); + ptpJitterNs_ = QStringLiteral("NA"); + ptpFrequencyPpb_ = QStringLiteral("0"); + ptpGlobalTraceable_ = false; + ptpMeasuredSmpSynch_ = QStringLiteral("NA"); + ptpRxAnnounce_ = QStringLiteral("0"); + ptpRxSync_ = QStringLiteral("0"); + ptpRxFollowUp_ = QStringLiteral("0"); + ptpRxPdelay_ = QStringLiteral("0"); + ptpPdelayRequests_ = QStringLiteral("0"); + ptpAccepted_ = QStringLiteral("0"); + ptpRejected_ = QStringLiteral("0"); + smpSynchMode_ = QStringLiteral("AUTO"); + smpSynchValue_ = QStringLiteral("0"); + smpSynchSource_ = QStringLiteral("SAFE_DEFAULT"); + smpSynchSimulated_ = false; + smpSynchMeasured_ = false; +} + QString DeviceController::cleanLine(const QString& rawLine) { QString line = rawLine; line.remove(kAnsiExpression); From 33177b524e2162ef68e151477784f977ca04b544 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:27:05 +0700 Subject: [PATCH 222/272] S7: render typed timing state and submit supervisor intents --- apps/arstack_studio/qml/PtpExpertPanel.qml | 165 +++++++++------------ 1 file changed, 72 insertions(+), 93 deletions(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 865edc530..bb9f7a4d6 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -6,70 +6,12 @@ import QtQuick.Layouts SurfacePanel { id: panel property var device + property var session property string uiFont: "Inter" property string monoFont: "Inter" property var syncValues: ["AUTO", "0", "1", "2"] property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] - function latestSync() { - const lines = String(device.logText).split("\n") - for (let i = lines.length - 1; i >= 0; --i) { - const m = lines[i].match(/SMPSYNCH mode=([A-Z0-9_]+) advertised=([0-2]) source=([A-Z_]+) simulated=([01]) measured=([01])/) - if (m) return { mode: m[1], value: m[2], source: m[3], simulated: m[4] === "1", measured: m[5] === "1" } - } - return { mode: "AUTO", value: "0", source: "SAFE_DEFAULT", simulated: false, measured: false } - } - - function emptyPtpState(role) { - return { - role: role, - discipline: "UNLOCKED", - source: "NONE", - offset: "NA", - path: "NA", - jitter: "NA", - freq: "0", - global: false, - measured: "NA", - rxAnnounce: "0", - rxSync: "0", - rxFollowUp: "0", - rxPdelay: "0", - pdelayReq: "0", - accepted: "0", - rejected: "0" - } - } - - function latestPtpState() { - const lines = String(device.logText).split("\n") - for (let i = lines.length - 1; i >= 0; --i) { - // Both PTPROLE and PTP2 are role-bearing events. Whichever appears - // newest is authoritative. A stopped role change therefore resets - // stale timing metrics immediately until a fresh PTP2 snapshot for - // that role arrives. - const role = lines[i].match(/PTPROLE role=(SOURCE|RECEIVER|MONITOR)/) - if (role) return emptyPtpState(role[1]) - - const m = lines[i].match(/PTP2 role=(SOURCE|RECEIVER|MONITOR) discipline=(UNLOCKED|ACQUIRING|LOCKED|HOLDOVER|FAULT) source=(\S+) offset=(NA|-?\d+) path=(NA|-?\d+) jitter=(NA|-?\d+) freq=(-?\d+) global=([01]) measured=(NA|[012]) rxAnnounce=(\d+) rxSync=(\d+) rxFollowUp=(\d+) rxPdelay=(\d+) pdelayReq=(\d+) accepted=(\d+) rejected=(\d+)/) - if (m) return { - role: m[1], discipline: m[2], source: m[3], offset: m[4], path: m[5], jitter: m[6], freq: m[7], - global: m[8] === "1", measured: m[9], rxAnnounce: m[10], rxSync: m[11], rxFollowUp: m[12], - rxPdelay: m[13], pdelayReq: m[14], accepted: m[15], rejected: m[16] - } - } - return emptyPtpState("SOURCE") - } - - function latestTx() { - const lines = String(device.logText).split("\n") - for (let i = lines.length - 1; i >= 0; --i) { - const m = lines[i].match(/PTP status=(RUNNING|STOPPED) Announce=(\d+) Sync=(\d+) FollowUp=(\d+) PdelayFrames=(\d+) TXfail=(\d+)/) - if (m) return { followUp: m[4], pdelay: m[5] } - } - return { followUp: "—", pdelay: "—" } - } - function syncIndex(mode) { if (mode === "FORCE_0") return 1 if (mode === "FORCE_1_LOCAL") return 2 @@ -88,7 +30,8 @@ SurfacePanel { } function applyProfile() { - device.configurePtp({ + if (!panel.session) return false + return panel.session.requestConfigurePtp({ domain: Number(domainField.text), transportSpecific: Number(transportField.text), vlanEnabled: vlanCheck.checked, @@ -100,12 +43,10 @@ SurfacePanel { }) } - readonly property var sync: latestSync() - readonly property var p2: latestPtpState() - readonly property var tx: latestTx() - readonly property bool receiverRole: p2.role === "RECEIVER" - readonly property bool monitorRole: p2.role === "MONITOR" - readonly property bool sourceRole: p2.role === "SOURCE" + readonly property string role: panel.device.ptpRole + readonly property bool receiverRole: role === "RECEIVER" + readonly property bool monitorRole: role === "MONITOR" + readonly property bool sourceRole: role === "SOURCE" ColumnLayout { anchors.fill: parent @@ -123,8 +64,12 @@ SurfacePanel { StateBadge { theme: panel.theme monoFont: panel.uiFont - state: panel.device.ptpRunning ? (panel.receiverRole ? panel.p2.discipline : panel.p2.role + " ACTIVE") : panel.p2.role + " STOPPED" - stateColor: panel.p2.discipline === "LOCKED" ? panel.theme.green : panel.p2.discipline === "FAULT" ? panel.theme.red : panel.device.ptpRunning ? panel.theme.amber : panel.theme.muted + state: panel.device.ptpRunning + ? (panel.receiverRole ? panel.device.ptpDiscipline : panel.role + " ACTIVE") + : panel.role + " STOPPED" + stateColor: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green + : panel.device.ptpDiscipline === "FAULT" ? panel.theme.red + : panel.device.ptpRunning ? panel.theme.amber : panel.theme.muted } } @@ -153,15 +98,17 @@ SurfacePanel { ComboBox { Layout.fillWidth: true model: ["Lab source — emit timing", "Time receiver — measured lock", "Monitor — passive"] - currentIndex: panel.roleIndex(panel.p2.role) - enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning + currentIndex: panel.roleIndex(panel.role) + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware font.family: panel.uiFont font.pixelSize: 9 - onActivated: panel.device.setPtpRole(panel.roleValues[currentIndex]) + onActivated: panel.session.requestPtpRole(panel.roleValues[currentIndex]) } Label { Layout.preferredWidth: 230 - text: panel.receiverRole ? "AUTO follows measured lock evidence." : panel.monitorRole ? "No clock discipline or AUTO promotion." : "Conservative lab source; no traceability claim." + text: panel.receiverRole ? "AUTO follows measured lock evidence." + : panel.monitorRole ? "No clock discipline or AUTO promotion." + : "Conservative lab source; no traceability claim." wrapMode: Text.WordWrap color: panel.theme.textSoft font.family: panel.uiFont @@ -212,17 +159,17 @@ SurfacePanel { rowSpacing: 4 columnSpacing: 12 Label { text: "Discipline"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.p2.discipline; color: panel.p2.discipline === "LOCKED" ? panel.theme.green : panel.p2.discipline === "FAULT" ? panel.theme.red : panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { text: panel.device.ptpDiscipline; color: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green : panel.device.ptpDiscipline === "FAULT" ? panel.theme.red : 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.p2.source === "NONE" ? "—" : panel.p2.source; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9; elide: Text.ElideMiddle } + Label { text: panel.device.ptpSource === "NONE" ? "—" : panel.device.ptpSource; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9; elide: Text.ElideMiddle } Label { text: "Offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.p2.offset, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: panel.showMetric(panel.device.ptpOffsetNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } Label { text: "Mean path delay"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.p2.path, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: panel.showMetric(panel.device.ptpPathDelayNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } Label { text: "Path jitter"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.p2.jitter, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: panel.showMetric(panel.device.ptpJitterNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } Label { text: "Clock correction"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.p2.freq + " ppb"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: panel.device.ptpFrequencyPpb + " ppb"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } } } @@ -238,24 +185,30 @@ SurfacePanel { ComboBox { Layout.fillWidth: true model: ["AUTO — measured PTP only", "0 — Not synchronized", "1 — Local synchronized (lab)", "2 — Global synchronized (lab)"] - currentIndex: panel.syncIndex(panel.sync.mode) - enabled: panel.device.deviceVerified + currentIndex: panel.syncIndex(panel.device.smpSynchMode) + enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware font.family: panel.uiFont font.pixelSize: 9 - onActivated: panel.device.setSmpSynchPolicy(panel.syncValues[currentIndex]) + onActivated: panel.session.requestSmpSynch(panel.syncValues[currentIndex]) } } StateBadge { theme: panel.theme monoFont: panel.uiFont - state: "smpSynch=" + panel.sync.value + (panel.sync.simulated ? " SIMULATED" : panel.sync.measured ? " MEASURED" : " SAFE") - stateColor: panel.sync.simulated ? panel.theme.amber : panel.sync.measured ? panel.theme.green : panel.theme.muted + state: "smpSynch=" + panel.device.smpSynchValue + + (panel.device.smpSynchSimulated ? " SIMULATED" : panel.device.smpSynchMeasured ? " MEASURED" : " SAFE") + stateColor: panel.device.smpSynchSimulated ? panel.theme.amber + : panel.device.smpSynchMeasured ? panel.theme.green : panel.theme.muted } } Label { Layout.fillWidth: true - text: panel.sync.mode === "AUTO" && panel.receiverRole ? "AUTO: LOCKED non-traceable → 1, LOCKED traceable → 2, bounded HOLDOVER → 1, otherwise 0." : panel.sync.mode === "AUTO" ? "AUTO remains 0 in SOURCE/MONITOR because those roles do not prove local clock discipline." : "Forced 0/1/2 is a live receiver-condition stimulus independent of measured clock lock." + text: panel.device.smpSynchMode === "AUTO" && panel.receiverRole + ? "AUTO: LOCKED non-traceable → 1, LOCKED traceable → 2, bounded HOLDOVER → 1, otherwise 0." + : panel.device.smpSynchMode === "AUTO" + ? "AUTO remains 0 in SOURCE/MONITOR because those roles do not prove local clock discipline." + : "Forced 0/1/2 is a live receiver-condition stimulus independent of measured clock lock." wrapMode: Text.WordWrap color: panel.theme.textSoft font.family: panel.uiFont @@ -272,22 +225,48 @@ SurfacePanel { Label { text: panel.sourceRole ? "FollowUp TX" : "FollowUp RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } Label { text: panel.sourceRole ? "Pdelay TX" : "Pdelay RX / Req"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } Label { text: panel.sourceRole ? "TX failures" : "Accepted / rejected"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? panel.device.ptpAnnounceSent : panel.p2.rxAnnounce; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.device.ptpSyncSent : panel.p2.rxSync; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.tx.followUp : panel.p2.rxFollowUp; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.tx.pdelay : panel.p2.rxPdelay + " / " + panel.p2.pdelayReq; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.device.ptpTxFailures : panel.p2.accepted + " / " + panel.p2.rejected; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpAnnounceSent : panel.device.ptpRxAnnounce; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpSyncSent : panel.device.ptpRxSync; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpFollowUpSent : panel.device.ptpRxFollowUp; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpPdelayFrames : panel.device.ptpRxPdelay + " / " + panel.device.ptpPdelayRequests; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpTxFailures : panel.device.ptpAccepted + " / " + panel.device.ptpRejected; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } } Item { Layout.fillHeight: true } RowLayout { Layout.fillWidth: true - CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Refresh"; enabled: panel.device.deviceVerified; onClicked: { panel.device.sendPtpShow(); panel.device.sendSmpSynchShow() } } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh" + enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware + onClicked: panel.session.requestPtpRefresh() + } Item { Layout.fillWidth: true } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Apply profile"; enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning; onClicked: panel.applyProfile() } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "danger"; text: "Stop PTP"; enabled: panel.device.ptpRunning; onClicked: panel.device.stopPtp() } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "accent"; text: panel.receiverRole ? "Start receiver" : panel.monitorRole ? "Start monitor" : "Start source"; enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning; onClicked: panel.device.startPtp() } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Apply profile" + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware + onClicked: panel.applyProfile() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + tone: "danger" + text: "Stop PTP" + enabled: panel.session && panel.device.ptpRunning && !panel.session.updatingFirmware + onClicked: panel.session.requestStopPtp() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + tone: "accent" + text: panel.receiverRole ? "Start receiver" : panel.monitorRole ? "Start monitor" : "Start source" + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware + onClicked: panel.session.requestStartPtp() + } } } } From 86a5a41ada9144b694845ae0ef642d6a70ef3d89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:27:27 +0700 Subject: [PATCH 223/272] S7: pass session authority into advanced panels --- apps/arstack_studio/qml/ConfigurationHub.qml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 697279b6e..4ce687be9 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -10,6 +10,7 @@ Item { property var controller property var device property var profiles + property var session property string uiFont: "Inter" property string monoFont: "Inter" @@ -138,6 +139,7 @@ Item { controller: hub.controller device: hub.device profiles: hub.profiles + session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont compact: false @@ -146,6 +148,7 @@ Item { theme: hub.theme device: hub.device firmware: FirmwareService + session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont } @@ -158,12 +161,14 @@ Item { PtpExpertPanel { theme: hub.theme device: hub.device + session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont } DeviceExpertPanel { theme: hub.theme device: hub.device + session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont } From 75e05eb71b198e73b280ff01db5baf4e36f16f72 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:28:15 +0700 Subject: [PATCH 224/272] S7: route firmware panel port refresh through supervisor --- apps/arstack_studio/qml/FirmwarePanel.qml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 35e0690a3..64a813992 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -7,6 +7,7 @@ SurfacePanel { id: panel property var device property var firmware + property var session property string uiFont: "Inter" property string monoFont: "Inter" property bool technicalDetailsVisible: false @@ -64,7 +65,7 @@ SurfacePanel { } } - Component.onCompleted: panel.device.refreshPorts() + Component.onCompleted: if (panel.session) panel.session.requestRefreshPorts() ColumnLayout { anchors.fill: parent @@ -214,15 +215,15 @@ SurfacePanel { id: firmwarePort Layout.preferredWidth: 205 model: panel.device.ports - enabled: !panel.firmware.busy - onPressedChanged: if (pressed) panel.device.refreshPorts() + enabled: panel.session && !panel.firmware.busy + onPressedChanged: if (pressed && panel.session) panel.session.requestRefreshPorts() } CalmButton { theme: panel.theme uiFont: panel.uiFont text: "Refresh" - enabled: !panel.firmware.busy - onClicked: panel.device.refreshPorts() + enabled: panel.session && !panel.firmware.busy + onClicked: panel.session.requestRefreshPorts() } Item { Layout.fillWidth: true } } From 147df772a6e259a358c46e62779aefb6dc8b196e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:30:01 +0700 Subject: [PATCH 225/272] S7: make Main submit device intents through supervisor --- apps/arstack_studio/qml/Main.qml | 63 ++++++++++++++++---------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index d016f6974..80bb38cac 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -47,11 +47,11 @@ ApplicationWindow { font.family: root.uiFont - // Graceful window close is still a safety boundary. The firmware session - // lease covers hard crashes; a normal close should explicitly request STOP. + // Graceful window close is still a safety boundary. QML submits only the + // Stop intent; the supervisor owns whether the current session may act. onClosing: function(close) { - if (device.running) - device.stop() + if (workflowBar.session && workflowBar.session.state === "RUNNING") + workflowBar.session.requestStop() } FontLoader { @@ -60,14 +60,8 @@ ApplicationWindow { } readonly property bool compactLayout: width < 1300 - readonly property bool selectedProfileDeployable: - sclProfiles.selectedProfile.compatibilityClass === "A" && - sclProfiles.selectedProfile.deviceSupport === "ready" - readonly property bool canDeploy: - device.deviceVerified && !device.running && selectedProfileDeployable && !device.profileDeploying - readonly property bool canStart: - device.deviceVerified && !device.running && - (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) + readonly property bool canDeploy: workflowBar.session ? workflowBar.session.canDeployProfile : false + readonly property bool canStart: workflowBar.session ? workflowBar.session.startReady : false readonly property string toastMessage: transientMessage.length ? transientMessage : device.lastError readonly property bool toastError: transientMessage.length ? transientError : device.lastError.length > 0 @@ -157,7 +151,7 @@ ApplicationWindow { } function deploySelectedProfile() { - if (canDeploy && device.deployProfile(sclProfiles.selectedProfile)) + if (canDeploy && workflowBar.session.requestProfileSync()) profileDirty = true } @@ -247,9 +241,9 @@ ApplicationWindow { } function sendSignal(group, row) { - if (!device.deviceVerified) return true + if (!workflowBar.session || !workflowBar.session.liveControlReady) return true var signal = groupModel(group).get(row) - return device.setSignal( + return workflowBar.session.requestSetSignal( signal.signalId, signal.magnitude, signal.phase, @@ -290,7 +284,7 @@ ApplicationWindow { } selectSignal(group, row) - if (device.deviceVerified) { + if (workflowBar.session && workflowBar.session.liveControlReady) { if (phaseLink && row < 3) sendLinkedGroup(group) else sendSignal(group, row) } @@ -298,11 +292,11 @@ ApplicationWindow { } function applyAllSignals() { - if (!device.deviceVerified) return - device.setFrequency(signalFrequency) + if (!workflowBar.session || !workflowBar.session.liveControlReady) return + workflowBar.session.requestSetFrequency(signalFrequency) for (var group = 0; group < 2; ++group) applyGroupSignals(group) - device.setCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, - ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + workflowBar.session.requestSetCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) } function setFrequencyValue(value) { @@ -312,7 +306,8 @@ ApplicationWindow { frequencyField.text = value.toFixed(3) frequencyField.invalidInput = false refreshPreview() - if (device.deviceVerified) device.setFrequency(value) + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetFrequency(value) return true } @@ -335,9 +330,9 @@ ApplicationWindow { } ctSaturationEnabled = enabled refreshPreview() - if (device.deviceVerified) - device.setCtSaturation(enabled, ctDcOffsetPercent, - ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) showMessage(enabled ? "CT saturation stress enabled · DC offset + 2nd harmonic + clipping approximation." : "CT saturation stress disabled.", false) @@ -356,7 +351,7 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (device.deviceVerified) applyAllSignals() + if (workflowBar.session && workflowBar.session.liveControlReady) applyAllSignals() } function zeroAll() { @@ -366,20 +361,22 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (device.deviceVerified) device.zero() + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestZero() } function setActiveQuality(value) { var unsignedValue = Number(value) >>> 0 groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue - if (device.deviceVerified) device.setQuality(activeSignal, unsignedValue) + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetQuality(activeSignal, unsignedValue) } Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() } Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() } - Shortcut { sequence: "F5"; enabled: !device.running; onActivated: workflowBar.requestStart() } - Shortcut { sequence: "F6"; enabled: device.running; onActivated: workflowBar.requestStop() } + Shortcut { sequence: "F5"; enabled: workflowBar.session && workflowBar.session.state !== "RUNNING"; onActivated: workflowBar.requestStart() } + Shortcut { sequence: "F6"; enabled: workflowBar.session && workflowBar.session.state === "RUNNING"; onActivated: workflowBar.requestStop() } Dialog { id: diagnosticsDialog @@ -503,7 +500,9 @@ ApplicationWindow { hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: !device.discovering - onClicked: device.deviceVerified ? root.openConfiguration() : device.autoDetectAndConnect() + onClicked: device.deviceVerified + ? root.openConfiguration() + : workflowBar.session.requestConnect() } ToolTip.visible: identityMouse.containsMouse ToolTip.text: workflowBar.session.statusText @@ -574,6 +573,7 @@ ApplicationWindow { controller: root device: device profiles: sclProfiles + session: workflowBar.session uiFont: root.uiFont monoFont: root.monoFont } @@ -782,7 +782,8 @@ ApplicationWindow { root.signalFrequency = value if (value > 0) root.previousAcFrequency = value root.refreshPreview() - if (device.deviceVerified) device.setFrequency(value) + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetFrequency(value) } else invalidInput = true } onEditingFinished: { From 607f6e7705c4651b7497fe9a4c8b784e846e1921 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:30:54 +0700 Subject: [PATCH 226/272] S7: gate engineering editor from supervisor state --- apps/arstack_studio/qml/ProfileInspector.qml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index 8cba254c4..9785fd14e 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -8,6 +8,7 @@ SurfacePanel { property var controller property var device property var profiles + property var session property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false @@ -98,7 +99,7 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont text: "Open SCL / CID" - enabled: !panel.device.running + enabled: panel.session && panel.session.engineeringEditable onClicked: panel.controller.openEngineeringFile() } @@ -121,7 +122,7 @@ SurfacePanel { model: panel.profiles textRole: "control" currentIndex: panel.profiles.selectedIndex - enabled: !panel.device.running + enabled: panel.session && panel.session.engineeringEditable font.family: panel.uiFont font.pixelSize: panel.theme.labelSize onActivated: { @@ -237,6 +238,7 @@ SurfacePanel { NumericField { id: counterField Layout.fillWidth: true + enabled: panel.session && panel.session.engineeringEditable theme: panel.theme monoFont: panel.monoFont compact: true @@ -247,6 +249,7 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont text: "Confirm" + enabled: panel.session && panel.session.engineeringEditable onClicked: { if (counterField.acceptableInput && panel.profiles.confirmCounterModulus(parseInt(counterField.text))) { panel.controller.profileDirty = true @@ -279,7 +282,8 @@ SurfacePanel { var value = panel.controller.parseOperatorNumber(text) if (acceptableInput && isFinite(value) && value > 0) { panel.controller.currentScale = value - if (panel.device.connected) panel.controller.applyGroupSignals(0) + if (panel.session && panel.session.liveControlReady) + panel.controller.applyGroupSignals(0) } else { text = panel.controller.currentScale.toString() } @@ -303,7 +307,8 @@ SurfacePanel { var value = panel.controller.parseOperatorNumber(text) if (acceptableInput && isFinite(value) && value > 0) { panel.controller.voltageScale = value - if (panel.device.connected) panel.controller.applyGroupSignals(1) + if (panel.session && panel.session.liveControlReady) + panel.controller.applyGroupSignals(1) } else { text = panel.controller.voltageScale.toString() } From 35af57486221aaba8ba6f4771eebc760939bbf97 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:32:11 +0700 Subject: [PATCH 227/272] S7: cover channel enable intent in supervisor API --- .../src/SmartSessionController.hpp | 102 ++++-------------- 1 file changed, 21 insertions(+), 81 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index e8851372a..c77732b66 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -88,9 +88,6 @@ class SmartSessionController : public QObject { const QString& baseline, const QString& observed) noexcept; - // Firmware handoff is a safety transaction, not an open-ended UI state. - // STOP confirmation and serial release are independently bounded so a lost - // callback cannot leave Studio stuck in UPDATING FIRMWARE forever. [[nodiscard]] static constexpr int firmwareStopAckTimeoutMs() noexcept { return 3500; } [[nodiscard]] static constexpr int firmwareReleaseAckTimeoutMs() noexcept { return 2000; } @@ -138,6 +135,9 @@ class SmartSessionController : public QObject { quint32 quality, double currentCountsPerAmp, double voltageCountsPerVolt); + Q_INVOKABLE bool requestSetEnabled(const QString& signalId, const bool enabled) { + return liveControlReady() && device_ != nullptr && device_->setEnabled(signalId, enabled); + } Q_INVOKABLE bool requestSetQuality(const QString& signalId, quint32 quality); Q_INVOKABLE bool requestSetCtSaturation( bool enabled, @@ -178,89 +178,53 @@ class SmartSessionController : public QObject { }; enum class ProfileSyncStage { idle, deploying, failed }; - // Small safety observer, deliberately not a second supervisor. It watches - // only the two acknowledgement barriers owned by this supervisor and - // terminates them if their expected callback never arrives. Repeated - // stateChanged notifications cannot extend a deadline for the same stage. class FirmwareHandoffWatchdog final { public: explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) { timer_.setSingleShot(true); - QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { - synchronize(); - }); - QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { - expire(); - }); + QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); }); + QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); }); } private: [[nodiscard]] bool waitingForAck() const noexcept { if (owner_ == nullptr || !owner_->updateRequested_) return false; if (owner_->updateStage_ == UpdateStage::stopping) return true; - return owner_->updateStage_ == UpdateStage::releasingPort && - owner_->pendingReleaseGeneration_ != 0; + return owner_->updateStage_ == UpdateStage::releasingPort && owner_->pendingReleaseGeneration_ != 0; } - [[nodiscard]] int timeoutForStage(const UpdateStage stage) const noexcept { - return stage == UpdateStage::stopping - ? firmwareStopAckTimeoutMs() - : firmwareReleaseAckTimeoutMs(); + return stage == UpdateStage::stopping ? firmwareStopAckTimeoutMs() : firmwareReleaseAckTimeoutMs(); } - void synchronize() { if (!waitingForAck()) { - timer_.stop(); - armedGeneration_ = 0; - armedStage_ = UpdateStage::idle; - return; - } - - if (timer_.isActive() && armedGeneration_ == owner_->sessionGeneration_ && - armedStage_ == owner_->updateStage_) { - return; + timer_.stop(); armedGeneration_ = 0; armedStage_ = UpdateStage::idle; return; } - + if (timer_.isActive() && armedGeneration_ == owner_->sessionGeneration_ && armedStage_ == owner_->updateStage_) return; armedGeneration_ = owner_->sessionGeneration_; armedStage_ = owner_->updateStage_; timer_.start(timeoutForStage(armedStage_)); } - void expire() { - if (!waitingForAck() || armedGeneration_ == 0 || - owner_->sessionGeneration_ != armedGeneration_ || - owner_->updateStage_ != armedStage_) { - synchronize(); - return; + if (!waitingForAck() || armedGeneration_ == 0 || owner_->sessionGeneration_ != armedGeneration_ || owner_->updateStage_ != armedStage_) { + synchronize(); return; } - const QString reason = armedStage_ == UpdateStage::stopping - ? QStringLiteral( - "Timed out waiting for the injector to confirm STOP. Firmware access was not started; retry explicitly after checking the device connection.") - : QStringLiteral( - "Timed out waiting for the serial worker to acknowledge port release. Firmware access was blocked to prevent COM-port contention."); - + ? QStringLiteral("Timed out waiting for the injector to confirm STOP. Firmware access was not started; retry explicitly after checking the device connection.") + : QStringLiteral("Timed out waiting for the serial worker to acknowledge port release. Firmware access was blocked to prevent COM-port contention."); owner_->latchFirmwareFailure(reason); emit owner_->firmwareUpdateFinished(false); owner_->reconcile(); } - SmartSessionController* owner_{nullptr}; QTimer timer_; quint64 armedGeneration_{0}; UpdateStage armedStage_{UpdateStage::idle}; }; - // S6 keeps re-enumeration recovery inside the existing supervisor. This - // observer remembers the semantic device_id of a verified board and only - // permits an automatic post-loss recovery to converge on that same board, - // even when Windows assigns a different COM number after reset/replug. class DeviceRecoveryMonitor final { public: explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) { - QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { - bindDevice(); - }); + QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); }); } private: @@ -274,43 +238,29 @@ class SmartSessionController : public QObject { expectedRecoveryDeviceId_ = lastVerifiedDeviceId_; } if (device_ == nullptr) return; - - verifiedConnection_ = QObject::connect( - device_, &DeviceController::deviceVerifiedChanged, owner_, [this] { - handleVerificationChange(); - }); - identificationConnection_ = QObject::connect( - device_, &DeviceController::identificationStateChanged, owner_, [this] { - handleIdentificationChange(); - }); + verifiedConnection_ = QObject::connect(device_, &DeviceController::deviceVerifiedChanged, owner_, [this] { handleVerificationChange(); }); + identificationConnection_ = QObject::connect(device_, &DeviceController::identificationStateChanged, owner_, [this] { handleIdentificationChange(); }); } - void setRecoveryPending(const bool pending) { if (owner_ == nullptr || owner_->recoveryPending_ == pending) return; owner_->recoveryPending_ = pending; emit owner_->stateChanged(); } - void handleVerificationChange() { if (owner_ == nullptr || device_ == nullptr) return; - if (device_->deviceVerified()) { const QString observed = device_->deviceId().trimmed(); if (owner_->recoveryPending_ && !expectedRecoveryDeviceId_.isEmpty() && - !SmartSessionController::recoveryIdentityMatches( - expectedRecoveryDeviceId_, observed)) { + !SmartSessionController::recoveryIdentityMatches(expectedRecoveryDeviceId_, observed)) { owner_->blankBoardDetected_ = false; owner_->blankBoardPort_.clear(); owner_->setupError_ = true; - owner_->setupErrorStatus_ = QStringLiteral( - "A different ARStack injector (%1) appeared while Studio was recovering device %2. Automatic recovery was blocked; reconnect the intended injector or retry explicitly.") - .arg(observed.isEmpty() ? QStringLiteral("unknown") : observed, - expectedRecoveryDeviceId_); + owner_->setupErrorStatus_ = QStringLiteral("A different ARStack injector (%1) appeared while Studio was recovering device %2. Automatic recovery was blocked; reconnect the intended injector or retry explicitly.") + .arg(observed.isEmpty() ? QStringLiteral("unknown") : observed, expectedRecoveryDeviceId_); device_->disconnectPort(); owner_->reconcile(); return; } - if (!observed.isEmpty()) { lastVerifiedDeviceId_ = observed; expectedRecoveryDeviceId_ = observed; @@ -319,7 +269,6 @@ class SmartSessionController : public QObject { wasVerified_ = true; return; } - if (wasVerified_ && !owner_->updateRequested_) { expectedRecoveryDeviceId_ = lastVerifiedDeviceId_; setRecoveryPending(!expectedRecoveryDeviceId_.isEmpty()); @@ -329,24 +278,15 @@ class SmartSessionController : public QObject { } wasVerified_ = false; } - void handleIdentificationChange() { if (owner_ == nullptr || device_ == nullptr || !owner_->recoveryPending_ || - device_->identificationState() != DeviceController::IdentificationState::Unidentified) { - return; - } - - // A timeout while recovering a previously verified injector is not - // evidence of blank firmware. Keep recovery explicit and never turn - // a transient CDC/re-enumeration delay into an Install prompt. + device_->identificationState() != DeviceController::IdentificationState::Unidentified) return; owner_->blankBoardDetected_ = false; owner_->blankBoardPort_.clear(); owner_->setupError_ = true; - owner_->setupErrorStatus_ = QStringLiteral( - "The previously verified injector did not answer semantic identity after bounded retries. Firmware absence was not inferred; retry identification after USB settles."); + owner_->setupErrorStatus_ = QStringLiteral("The previously verified injector did not answer semantic identity after bounded retries. Firmware absence was not inferred; retry identification after USB settles."); owner_->reconcile(); } - SmartSessionController* owner_{nullptr}; DeviceController* device_{nullptr}; QMetaObject::Connection verifiedConnection_; From 6439c931139933802680a0a4e40b030ae1db97f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:32:53 +0700 Subject: [PATCH 228/272] S7: route channel enable through supervisor --- apps/arstack_studio/qml/SignalMatrix.qml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 998015272..dae10bc78 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -8,6 +8,7 @@ Rectangle { property var theme property var controller property var device + property var session property var sourceModel property string uiFont: "Inter" property string monoFont: "Inter" @@ -164,8 +165,8 @@ Rectangle { matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) matrix.controller.refreshPreview() - if (matrix.device.deviceVerified) - matrix.device.setEnabled(signalRow.sid, checked) + if (matrix.session && matrix.session.liveControlReady) + matrix.session.requestSetEnabled(signalRow.sid, checked) } } @@ -206,9 +207,7 @@ Rectangle { invalidInput = false signalRow.pendingMagnitude = value magnitudeApplyTimer.restart() - } else { - invalidInput = true - } + } else invalidInput = true } onEditingFinished: { var value = matrix.controller.parseOperatorNumber(text) @@ -258,9 +257,7 @@ Rectangle { invalidInput = false signalRow.pendingPhase = value phaseApplyTimer.restart() - } else { - invalidInput = true - } + } else invalidInput = true } onEditingFinished: { var value = matrix.controller.parseOperatorNumber(text) From 1819fce0ae162bf5c57684af8bcbf9908f15c12f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:34:33 +0700 Subject: [PATCH 229/272] S7: wire signal matrices to supervisor session --- apps/arstack_studio/qml/Main.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 80bb38cac..af7fd321e 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -824,6 +824,7 @@ ApplicationWindow { theme: studioTheme controller: root device: device + session: workflowBar.session sourceModel: currentModel uiFont: root.uiFont monoFont: root.monoFont @@ -840,6 +841,7 @@ ApplicationWindow { theme: studioTheme controller: root device: device + session: workflowBar.session sourceModel: voltageModel uiFont: root.uiFont monoFont: root.monoFont From b4448de5a74899c03f87f0b4f906e654ee0590e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:37:13 +0700 Subject: [PATCH 230/272] S7: lock QML presentation-only authority in CI --- .github/workflows/arstack-studio-qt.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index a14123505..e5458a49c 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -64,6 +64,24 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: S7 QML presentation-only authority regression + shell: bash + run: | + set -euo pipefail + qml_root='apps/arstack_studio/qml' + device_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*device\.(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' + firmware_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*(firmware|FirmwareService)\.(probeTarget|installFirmware)[[:space:]]*\(' + + if grep -R -nE "$device_pattern" "$qml_root" --include='*.qml'; then + echo 'S7 regression: QML bypasses SmartSessionController with direct DeviceController authority.' + exit 1 + fi + if grep -R -nE "$firmware_pattern" "$qml_root" --include='*.qml'; then + echo 'S7 regression: QML bypasses SmartSessionController with direct firmware execution.' + exit 1 + fi + echo 'S7 QML authority boundary: PASS · presentation submits intents through SmartSessionController' + - name: Configure shell: bash run: | From f6ac7e9d11e8a5929c020560669de7b62d2ffd70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:38:37 +0700 Subject: [PATCH 231/272] S7: regression-lock fail-closed supervisor intents --- apps/arstack_studio/src/main.cpp | 34 ++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index fdd1c7b48..31c8e981d 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -325,13 +325,38 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QFile::remove(lockTestPath); const bool singleInstancePolicy = secondInstanceBlocked && lockReacquiredAfterRelease; + SmartSessionController detachedSupervisor; + const bool s7IntentPolicy = + !detachedSupervisor.startReady() && + !detachedSupervisor.canDeployProfile() && + !detachedSupervisor.liveControlReady() && + !detachedSupervisor.requestStart() && + !detachedSupervisor.requestStop() && + !detachedSupervisor.requestConnect() && + !detachedSupervisor.requestConnectPort(QStringLiteral("COM7")) && + !detachedSupervisor.requestDisconnect() && + !detachedSupervisor.requestProfileSync() && + !detachedSupervisor.requestSetFrequency(50.0) && + !detachedSupervisor.requestSetSignal( + QStringLiteral("IA"), 1.0, 0.0, 0U, 1000.0, 100.0) && + !detachedSupervisor.requestSetEnabled(QStringLiteral("IA"), true) && + !detachedSupervisor.requestSetQuality(QStringLiteral("IA"), 0U) && + !detachedSupervisor.requestSetCtSaturation(false, 0.0, 0.0, 2, 100.0) && + !detachedSupervisor.requestZero() && + !detachedSupervisor.requestPtpRefresh() && + !detachedSupervisor.requestPtpRole(QStringLiteral("SOURCE")) && + !detachedSupervisor.requestSmpSynch(QStringLiteral("AUTO")) && + !detachedSupervisor.requestConfigurePtp({}) && + !detachedSupervisor.requestStartPtp() && + !detachedSupervisor.requestStopPtp(); + if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || !rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy || !workerPolicyAligned || !generationPolicy || !portOwnershipPolicy || - !s6RecoveryPolicy || !singleInstancePolicy) { + !s6RecoveryPolicy || !singleInstancePolicy || !s7IntentPolicy) { qCritical().noquote() - << "S1/S2/S3/S4/S5/S6 control-plane contract: FAIL" + << "S1/S2/S3/S4/S5/S6/S7 control-plane contract: FAIL" << "current=" << currentAccepted << "legacy=" << legacyRejectedAsCurrent << "protocol-legacy=" << protocolLegacyParsed @@ -345,7 +370,8 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { << "generation-policy=" << generationPolicy << "port-owner-policy=" << portOwnershipPolicy << "s6-recovery-health=" << s6RecoveryPolicy - << "single-instance=" << singleInstancePolicy; + << "single-instance=" << singleInstancePolicy + << "s7-intent-fail-closed=" << s7IntentPolicy; return 11; } @@ -384,7 +410,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { return 6; } qInfo().noquote() - << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded profile sync + S4 threaded DeviceIoWorker + S5 generation/PortOwner/FirmwareWorker + S6 device-id recovery/health/single-instance boundaries + unverified START/DEPLOY fail closed"; + << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded profile sync + S4 threaded DeviceIoWorker + S5 generation/PortOwner/FirmwareWorker + S6 device-id recovery/health/single-instance + S7 supervisor-only operator intent boundaries + unverified START/DEPLOY fail closed"; return 0; } } // namespace From b777bf338a22452c5833c3d5226da658fedfdff8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 20:52:09 +0700 Subject: [PATCH 232/272] S7: make device mutation surface C++ supervisor-only --- apps/arstack_studio/src/DeviceController.hpp | 48 +++++++++++--------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 641c9491e..b97c5897a 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -183,26 +183,30 @@ class DeviceController : public QObject { void setSessionGeneration(quint64 generation); - Q_INVOKABLE void refreshPorts(); - Q_INVOKABLE bool autoDetectAndConnect(); - Q_INVOKABLE bool connectPort(const QString& portName); - Q_INVOKABLE void disconnectPort(); - Q_INVOKABLE bool sendShow(); - Q_INVOKABLE virtual bool start(); - Q_INVOKABLE bool stop(); - Q_INVOKABLE virtual bool zero(); - Q_INVOKABLE virtual bool setFrequency(double hz); - Q_INVOKABLE virtual bool setSignal( + // S7 authority boundary: device/session mutations are public C++ for the + // SmartSessionController but intentionally absent from the QML meta-object. + // This prevents aliases or future presentation code from bypassing the + // supervisor even if a source-policy grep were accidentally evaded. + void refreshPorts(); + bool autoDetectAndConnect(); + bool connectPort(const QString& portName); + void disconnectPort(); + bool sendShow(); + virtual bool start(); + bool stop(); + virtual bool zero(); + virtual bool setFrequency(double hz); + virtual bool setSignal( const QString& signalId, double magnitude, double phaseDegrees, quint32 quality, double currentCountsPerAmp, double voltageCountsPerVolt); - Q_INVOKABLE bool setEnabled(const QString& signalId, bool enabled); - Q_INVOKABLE bool setQuality(const QString& signalId, quint32 quality); - Q_INVOKABLE bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); - Q_INVOKABLE virtual bool deployProfile(const QVariantMap& profile); + bool setEnabled(const QString& signalId, bool enabled); + bool setQuality(const QString& signalId, quint32 quality); + bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); + virtual bool deployProfile(const QVariantMap& profile); void abandonProfileDeployment() { if (!profileDeploying_) return; @@ -211,12 +215,12 @@ class DeviceController : public QObject { emit profileStateChanged(); } - Q_INVOKABLE bool sendPtpShow(); - Q_INVOKABLE bool startPtp(); - Q_INVOKABLE bool stopPtp(); - Q_INVOKABLE bool configurePtp(const QVariantMap& profile); + bool sendPtpShow(); + bool startPtp(); + bool stopPtp(); + bool configurePtp(const QVariantMap& profile); - Q_INVOKABLE bool setPtpRole(const QString& requestedRole) { + bool setPtpRole(const QString& requestedRole) { const QString role = requestedRole.trimmed().toUpper(); static const QStringList validRoles{ QStringLiteral("SOURCE"), @@ -233,10 +237,10 @@ class DeviceController : public QObject { return sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role)); } - Q_INVOKABLE bool sendSmpSynchShow() { + bool sendSmpSynchShow() { return sendCommand(QStringLiteral("PROFILE SHOW")); } - Q_INVOKABLE bool setSmpSynchPolicy(const QString& requestedMode) { + bool setSmpSynchPolicy(const QString& requestedMode) { const QString mode = requestedMode.trimmed().toUpper(); static const QStringList validModes{ QStringLiteral("AUTO"), QStringLiteral("0"), QStringLiteral("1"), QStringLiteral("2")}; @@ -247,6 +251,8 @@ class DeviceController : public QObject { return sendCommand(QStringLiteral("PROFILE SMPSYNCH %1").arg(mode)); } + // Diagnostics are presentation-safe: clearing the local log does not touch + // serial ownership, output state, firmware or the device protocol. Q_INVOKABLE void clearLog(); signals: From fe7260ae231b799744b178162e59239beea362ad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:07:27 +0700 Subject: [PATCH 233/272] S8: add deterministic harness seam and explicit recovery intent state --- apps/arstack_studio/src/SmartSessionController.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index c77732b66..e6b6268a1 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -9,6 +9,7 @@ #include #include +class DeterministicSessionHarness; class FirmwareManager; class SclProfileModel; @@ -167,6 +168,8 @@ class SmartSessionController : public QObject { void firmwareUpdateFinished(bool success); private: + friend class DeterministicSessionHarness; + enum class UpdateStage { idle, stopping, @@ -344,6 +347,7 @@ class SmartSessionController : public QObject { bool needsProfileSync_{true}; bool updateRequested_{false}; bool blankBoardDetected_{false}; + bool manualRecoveryArmed_{false}; bool setupError_{false}; bool recoveryPending_{false}; int updateReconnectAttempts_{0}; From d0b9abd8f87336fcf2af9f7d9f1cff92504c5d60 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:08:24 +0700 Subject: [PATCH 234/272] S8: expose friend-only deterministic device state seam --- apps/arstack_studio/src/DeviceController.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index b97c5897a..661f64df0 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -6,6 +6,7 @@ #include #include +class DeterministicSessionHarness; class DeviceIoWorker; struct DeviceIdentity final { @@ -277,6 +278,8 @@ class DeviceController : public QObject { void setSessionHeartbeatEnabled(bool enabled); private: + friend class DeterministicSessionHarness; + bool sendCommand(const QString& command); void connectWorkerSignals(); void handlePortSnapshot(const QStringList& ports, const QString& recommendedPort, int highConfidenceCount); From b71f6994dc8e384b4b2cb2e6d38de42fc90fc252 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:09:09 +0700 Subject: [PATCH 235/272] S8: expose friend-only control-health harness seam --- apps/arstack_studio/src/StudioDeviceController.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 8882e37ff..778b7175c 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -14,6 +14,8 @@ #define ARSTACK_STUDIO_VERSION "0.1.0" #endif +class DeterministicSessionHarness; + class StudioDeviceController : public DeviceController { Q_OBJECT Q_PROPERTY(bool controlResponsive READ controlResponsive NOTIFY controlHealthChanged) @@ -199,6 +201,8 @@ class StudioDeviceController : public DeviceController { void controlHealthFailed(); private: + friend class DeterministicSessionHarness; + struct PendingSignal { double magnitude{}; double phaseDegrees{}; From 78253073ad467d1de07465a1a5851a8149411d07 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:11:00 +0700 Subject: [PATCH 236/272] S8: fail closed auto identity timeout and wrong-device recovery --- .../src/SmartSessionController.cpp | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index cf182032c..f04e8532b 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -187,6 +187,7 @@ bool SmartSessionController::startDeviceDiscovery() { device_->connected() || device_->discovering()) { return false; } + manualRecoveryArmed_ = false; advanceSessionGeneration(); setPortOwner(PortOwner::deviceSession); return device_->autoDetectAndConnect(); @@ -295,6 +296,7 @@ bool SmartSessionController::requestConnectPort(const QString& portName) { } clearBlankBoardContext(); + manualRecoveryArmed_ = true; advanceSessionGeneration(); setPortOwner(PortOwner::deviceSession); const bool accepted = device_->connectPort(requested); @@ -729,10 +731,20 @@ void SmartSessionController::refreshRecoveryOfferFromIdentity() { device_->identificationState() != DeviceController::IdentificationState::Unidentified)) { blankBoardDetected_ = false; blankBoardPort_.clear(); + manualRecoveryArmed_ = false; } return; } + // S8 policy: an automatic semantic IDENTIFY timeout means "unknown", never + // "blank firmware". The firmware-install offer is armed only after an + // explicit manual recovery selection (Verify selected port). + if (!manualRecoveryArmed_ || recoveryPending_) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + const QString recommended = device_->recommendedPort().trimmed(); if (recommended.isEmpty() || !device_->ports().contains(recommended)) { blankBoardDetected_ = false; @@ -747,6 +759,7 @@ void SmartSessionController::refreshRecoveryOfferFromIdentity() { void SmartSessionController::clearBlankBoardContext() { blankBoardDetected_ = false; blankBoardPort_.clear(); + manualRecoveryArmed_ = false; setupError_ = false; setupErrorStatus_.clear(); } @@ -945,6 +958,20 @@ void SmartSessionController::reconcile() { } } + // Fail closed immediately. In particular, a wrong-device recovery must not + // briefly fall through to READY while the asynchronous port close is still + // being acknowledged by DeviceIoWorker. + if (setupError_) { + setPresentation( + QStringLiteral("SETUP ERROR"), + setupErrorStatus_.isEmpty() + ? QStringLiteral("Studio could not identify a supported ESP32-P4 on the connected serial device.") + : setupErrorStatus_, + false, + false); + return; + } + if (device_->discovering() || (device_->connected() && !device_->deviceVerified())) { setPresentation(QStringLiteral("CONNECTING"), device_->discoveryStatus(), false, false); return; @@ -956,28 +983,17 @@ void SmartSessionController::reconcile() { if (blankBoardDetected_) { setPresentation( QStringLiteral("FIRMWARE REQUIRED"), - QStringLiteral("ARStack identity was not received from %1 after bounded retries. If this is the intended ESP32-P4, Studio will verify chip and revision before writing firmware.") + QStringLiteral("Manual recovery selected for %1 after bounded semantic identification failed. Studio will verify chip and revision before any firmware write.") .arg(blankBoardPort_), false, false); return; } - if (setupError_) { - setPresentation( - QStringLiteral("SETUP ERROR"), - setupErrorStatus_.isEmpty() - ? QStringLiteral("Studio could not identify a supported ESP32-P4 on the connected serial device.") - : setupErrorStatus_, - false, - false); - return; - } - if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) { setPresentation( QStringLiteral("UNIDENTIFIED"), - QStringLiteral("No ARStack semantic identity was received after %1 bounded attempts. Retry identification or select the intended device manually.") + QStringLiteral("No ARStack semantic identity was received after %1 bounded attempts. Retry identification, or use Tools > Advanced > Verify selected port to enter explicit firmware recovery.") .arg(device_->identifyAttempts()), false, false); From 51ec4f49e1b07f20e56ed3989a558d15e03b8206 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:11:49 +0700 Subject: [PATCH 237/272] S8: declare deterministic session harness --- apps/arstack_studio/src/DeterministicSessionHarness.hpp | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 apps/arstack_studio/src/DeterministicSessionHarness.hpp diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.hpp b/apps/arstack_studio/src/DeterministicSessionHarness.hpp new file mode 100644 index 000000000..ccd6b8c89 --- /dev/null +++ b/apps/arstack_studio/src/DeterministicSessionHarness.hpp @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +int runDeterministicSessionHarness(int argc, char* argv[]); From 6f6686f402fc5071398954021fc1057441929386 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:13:20 +0700 Subject: [PATCH 238/272] S8: add deterministic supervisor event-sequence harness --- .../src/DeterministicSessionHarness.cpp | 467 ++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 apps/arstack_studio/src/DeterministicSessionHarness.cpp diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp new file mode 100644 index 000000000..1411a68ac --- /dev/null +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -0,0 +1,467 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeterministicSessionHarness.hpp" + +#include "FirmwareManager.hpp" +#include "SclProfileModel.hpp" +#include "SmartSessionController.hpp" +#include "StudioDeviceController.hpp" + +#include +#include +#include + +#ifndef ARSTACK_STUDIO_VERSION +#define ARSTACK_STUDIO_VERSION "0.1.0" +#endif + +class DeterministicSessionHarness final { +public: + static int run(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + + struct CaseResult { + const char* name; + bool passed; + }; + + const CaseResult cases[] = { + {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, + {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, + {"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()}, + {"manual recovery intent -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, + {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, + {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, + {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, + {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, + {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, + {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, + {"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()}, + {"control health misses -> bounded failure", healthTimeoutIsBounded()}, + {"worker teardown -> joins cleanly", workerTeardownCompletes()}, + }; + + QStringList failures; + for (const auto& result : cases) { + qInfo().noquote() + << "S8 deterministic session:" << (result.passed ? "PASS" : "FAIL") + << "·" << result.name; + if (!result.passed) failures.push_back(QString::fromUtf8(result.name)); + } + + if (!failures.isEmpty()) { + qCritical().noquote() + << "S8 deterministic session harness: FAIL ·" + << failures.join(QStringLiteral("; ")); + return 21; + } + + qInfo().noquote() + << "S8 deterministic session harness: PASS · semantic identity/recovery, generation, ownership, profile terminal states, unplug/replug, wrong-device and health boundaries locked"; + return 0; + } + +private: + struct Fixture { + SclProfileModel profiles; + FirmwareManager firmware; + StudioDeviceController device; + SmartSessionController session; + bool profileReady{false}; + + Fixture() { + profileReady = profiles.loadReferenceTemplate(); + session.setProfiles(&profiles); + session.setFirmware(&firmware); + session.setDevice(&device); + session.started_ = true; + quiesce(session, device); + } + + ~Fixture() { + firmware.shutdown(); + } + }; + + static void quiesce(SmartSessionController& session, StudioDeviceController& device) { + session.discoveryTimer_.stop(); + session.prepareTimer_.stop(); + session.reconnectTimer_.stop(); + session.profileSyncTimer_.stop(); + device.controlHealthTimer_.stop(); + device.liveFlushTimer_.stop(); + } + + static DeviceIdentity identity( + const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), + const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), + const QString& bootId = QStringLiteral("0123456789ABCDEF")) { + DeviceIdentity result; + result.product = QStringLiteral("SMV-INJECTOR"); + result.target = QStringLiteral("ESP32-P4"); + result.protocolVersion = QStringLiteral("1"); + result.deviceId = deviceId; + result.firmwareVersion = firmware; + result.bootId = bootId; + result.capabilities = { + QStringLiteral("SMV-4I4V"), + QStringLiteral("LIVE-SETPOINTS"), + QStringLiteral("SESSION-LEASE")}; + return result; + } + + static void seedVerified( + Fixture& fixture, + const DeviceIdentity& identityValue, + const QString& port, + const bool running) { + auto& device = fixture.device; + auto& session = fixture.session; + + quiesce(session, device); + session.setupError_ = false; + session.setupErrorStatus_.clear(); + session.manualRecoveryArmed_ = false; + session.blankBoardDetected_ = false; + session.blankBoardPort_.clear(); + session.updateRequested_ = false; + session.updateStage_ = SmartSessionController::UpdateStage::idle; + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::idle; + session.profileSyncAttempts_ = 0; + session.needsProfileSync_ = false; + + device.ports_ = {port}; + device.recommendedPort_ = port; + device.portName_ = port; + device.connected_ = true; + device.discovering_ = false; + device.deviceVerified_ = true; + device.identificationState_ = DeviceController::IdentificationState::Verified; + device.identifyAttempts_ = 1; + device.identity_ = identityValue; + device.running_ = running; + device.profileDeploying_ = false; + device.profileArmed_ = true; + device.profileGeneration_ = QStringLiteral("7"); + device.lastError_.clear(); + + emit device.portsChanged(); + emit device.deviceIdentityChanged(); + emit device.identificationStateChanged(); + emit device.connectedChanged(); + emit device.deviceVerifiedChanged(); + emit device.profileStateChanged(); + emit device.runningChanged(); + + // deviceVerifiedChanged intentionally re-arms volatile profile sync in + // production. For these scenarios the harness is modelling the point + // after that transaction has already been confirmed. + session.prepareTimer_.stop(); + session.profileSyncTimer_.stop(); + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::idle; + session.profileSyncAttempts_ = 0; + session.profileSyncBaselineGeneration_ = device.profileGeneration_; + session.profileSyncBootId_ = identityValue.bootId; + session.profileSyncError_.clear(); + session.needsProfileSync_ = false; + device.profileDeploying_ = false; + device.profileArmed_ = true; + device.controlHealthTimer_.stop(); + session.reconcile(); + } + + static void seedIdentityTimeout(Fixture& fixture, const bool manualRecovery) { + auto& device = fixture.device; + auto& session = fixture.session; + + quiesce(session, device); + session.clearBlankBoardContext(); + session.manualRecoveryArmed_ = manualRecovery; + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.recoveryPending_ = false; + + device.ports_ = {QStringLiteral("COM7")}; + device.recommendedPort_ = QStringLiteral("COM7"); + device.portName_ = QStringLiteral("COM7"); + device.connected_ = false; + device.discovering_ = false; + device.deviceVerified_ = false; + device.identificationState_ = DeviceController::IdentificationState::Unidentified; + device.identifyAttempts_ = DeviceController::identityMaxAttempts(); + device.identity_ = {}; + device.running_ = false; + device.profileArmed_ = false; + device.profileDeploying_ = false; + + emit device.portsChanged(); + emit device.discoveryChanged(); + emit device.deviceIdentityChanged(); + emit device.identificationStateChanged(); + session.reconcile(); + } + + static void simulateDisconnect(Fixture& fixture) { + auto& device = fixture.device; + auto& session = fixture.session; + + quiesce(session, device); + device.connected_ = false; + device.discovering_ = false; + device.deviceVerified_ = false; + device.identificationState_ = DeviceController::IdentificationState::Idle; + device.identifyAttempts_ = 0; + device.identity_ = {}; + device.running_ = false; + device.profileArmed_ = false; + device.profileDeploying_ = false; + device.ports_.clear(); + device.recommendedPort_.clear(); + + emit device.runningChanged(); + emit device.profileStateChanged(); + emit device.deviceIdentityChanged(); + emit device.identificationStateChanged(); + emit device.deviceVerifiedChanged(); + emit device.connectedChanged(); + emit device.portsChanged(); + session.prepareTimer_.stop(); + session.profileSyncTimer_.stop(); + session.reconcile(); + } + + static bool currentIdentityNeverRecovers() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + return fixture.session.state() == QStringLiteral("READY") && + fixture.session.startReady() && + !fixture.session.firmwareInstallVisible() && + !fixture.session.firmwareUpdateRequired(); + } + + static bool legacyIdentityOffersUpdate() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified( + fixture, + identity(QStringLiteral("A1B2C3D4E5F6"), QStringLiteral("0.0.9")), + QStringLiteral("COM7"), + false); + return fixture.session.state() == QStringLiteral("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !fixture.session.startReady(); + } + + static bool automaticIdentityTimeoutStaysUnidentified() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedIdentityTimeout(fixture, false); + return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && + !fixture.session.firmwareInstallVisible() && + !fixture.session.blankBoardDetected_ && + !fixture.session.startReady(); + } + + static bool manualRecoveryIntentArmsFirmwareOffer() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedIdentityTimeout(fixture, true); + return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && + fixture.session.firmwareInstallVisible() && + fixture.session.blankBoardPort_ == QStringLiteral("COM7") && + !fixture.session.startReady(); + } + + static bool staleReleaseGenerationIsIgnored() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto& session = fixture.session; + auto& device = fixture.device; + const quint64 current = session.sessionGeneration_ == 0 ? 1 : session.sessionGeneration_; + session.sessionGeneration_ = current; + device.setSessionGeneration(current); + session.updateRequested_ = true; + session.updateStage_ = SmartSessionController::UpdateStage::releasingPort; + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.pendingReleaseGeneration_ = current; + + emit device.portReleased(current - 1, QStringLiteral("COM7")); + const bool staleIgnored = + session.pendingReleaseGeneration_ == current && + session.portOwner_ == SmartSessionController::PortOwner::deviceSession && + session.updateStage_ == SmartSessionController::UpdateStage::releasingPort; + + emit device.portReleased(current, QStringLiteral("COM7")); + const bool currentAccepted = + session.pendingReleaseGeneration_ == 0 && + session.portOwner_ == SmartSessionController::PortOwner::none; + + session.updateRequested_ = false; + session.updateStage_ = SmartSessionController::UpdateStage::idle; + return staleIgnored && currentAccepted; + } + + static bool firmwareProbeRequiresReleasedPort() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto& session = fixture.session; + auto& device = fixture.device; + device.connected_ = true; + session.updateRequested_ = true; + session.updatePort_ = QStringLiteral("COM7"); + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.updateStage_ = SmartSessionController::UpdateStage::releasingPort; + const bool blocked = !session.startFirmwareProbe(); + session.updateRequested_ = false; + session.updateStage_ = SmartSessionController::UpdateStage::idle; + return blocked && session.portOwner_ == SmartSessionController::PortOwner::deviceSession; + } + + static bool profileTimeoutIsTerminal() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = 1; + device.profileDeploying_ = false; + device.profileArmed_ = false; + session.handleProfileSyncAttemptFailure(QStringLiteral("first timeout")); + const bool oneRetryRemains = + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::idle && + session.profileSyncAttempts_ == 1; + + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = SmartSessionController::profileSyncMaxAttempts(); + device.profileDeploying_ = false; + session.handleProfileSyncAttemptFailure(QStringLiteral("second timeout")); + session.reconcile(); + + return oneRetryRemains && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::failed && + session.state() == QStringLiteral("PROFILE SYNC ERROR") && + session.profileSyncRetryAvailable() && + !session.startReady(); + } + + static bool profileRejectionIsTerminal() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = SmartSessionController::profileSyncMaxAttempts(); + device.profileDeploying_ = false; + device.profileArmed_ = false; + device.lastError_ = QStringLiteral("Device rejected the profile."); + session.handleProfileStateChanged(); + session.reconcile(); + + return session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::failed && + session.state() == QStringLiteral("PROFILE SYNC ERROR") && + session.statusText().contains(QStringLiteral("rejected"), Qt::CaseInsensitive) && + !session.startReady(); + } + + static bool readyReplugRecoversWithoutStart() { + Fixture fixture; + if (!fixture.profileReady) return false; + const auto board = identity(); + seedVerified(fixture, board, QStringLiteral("COM7"), false); + if (fixture.session.state() != QStringLiteral("READY")) return false; + + simulateDisconnect(fixture); + const bool disconnected = fixture.session.recoveryPending_ && + !fixture.session.startReady() && + fixture.session.state() != QStringLiteral("READY") && + fixture.session.state() != QStringLiteral("RUNNING"); + + seedVerified(fixture, board, QStringLiteral("COM11"), false); + return disconnected && + !fixture.session.recoveryPending_ && + fixture.session.state() == QStringLiteral("READY") && + !fixture.device.running() && + fixture.device.portName() == QStringLiteral("COM11"); + } + + static bool runningReplugNeverAutoStarts() { + Fixture fixture; + if (!fixture.profileReady) return false; + const auto board = identity(); + seedVerified(fixture, board, QStringLiteral("COM7"), true); + if (fixture.session.state() != QStringLiteral("RUNNING")) return false; + + simulateDisconnect(fixture); + if (fixture.session.state() == QStringLiteral("RUNNING") || fixture.device.running()) return false; + + seedVerified(fixture, board, QStringLiteral("COM12"), false); + return fixture.session.state() == QStringLiteral("READY") && + fixture.session.startReady() && + !fixture.device.running(); + } + + static bool wrongDeviceRecoveryFailsClosed() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(QStringLiteral("A1B2C3D4E5F6")), QStringLiteral("COM7"), false); + simulateDisconnect(fixture); + if (!fixture.session.recoveryPending_) return false; + + seedVerified( + fixture, + identity(QStringLiteral("001122334455"), QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("FEDCBA9876543210")), + QStringLiteral("COM14"), + false); + + return fixture.session.setupError_ && + fixture.session.state() == QStringLiteral("SETUP ERROR") && + fixture.session.statusText().contains(QStringLiteral("different ARStack injector"), Qt::CaseInsensitive) && + !fixture.session.startReady(); + } + + static bool healthTimeoutIsBounded() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& device = fixture.device; + + bool failed = false; + QObject::connect(&device, &StudioDeviceController::controlHealthFailed, &device, [&failed] { + failed = true; + }); + + device.controlHealthTimer_.stop(); + device.controlResponsive_ = true; + device.healthProbeOutstanding_ = true; + device.missedHealthReplies_ = 0; + device.serviceControlHealth(); + const bool degraded = + !device.controlResponsive_ && device.missedHealthReplies_ == 1; + device.serviceControlHealth(); + + return degraded && failed && + device.missedHealthReplies_ == StudioDeviceController::controlHealthMaxMisses() && + !device.controlResponsive_ && + !device.healthProbeOutstanding_; + } + + static bool workerTeardownCompletes() { + bool profileReady = false; + { + Fixture fixture; + profileReady = fixture.profileReady; + } + return profileReady; + } +}; + +int runDeterministicSessionHarness(int argc, char* argv[]) { + return DeterministicSessionHarness::run(argc, argv); +} From 1be4855b83594dcfc78caea75f08adc84e89361e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:14:36 +0700 Subject: [PATCH 239/272] S7/S8: close derived QML mutation surface --- apps/arstack_studio/src/StudioDeviceController.hpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 778b7175c..19d115b42 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -102,7 +102,7 @@ class StudioDeviceController : public DeviceController { [[nodiscard]] static constexpr int controlHealthProbeIntervalMs() noexcept { return 2000; } [[nodiscard]] static constexpr int controlHealthMaxMisses() noexcept { return 2; } - Q_INVOKABLE bool start() override { + bool start() override { if (!deviceVerified()) { emit deviceMessage(QStringLiteral("Connect and verify the ARStack ESP32-P4 before Start.")); return false; @@ -130,13 +130,13 @@ class StudioDeviceController : public DeviceController { return DeviceController::start(); } - Q_INVOKABLE bool zero() override { + bool zero() override { pendingSignals_.clear(); if (!frequencyPending_) liveFlushTimer_.stop(); return DeviceController::zero(); } - Q_INVOKABLE bool setFrequency(const double hz) override { + bool setFrequency(const double hz) override { if (!std::isfinite(hz) || hz < 0.0 || hz > 1000.0) { emit deviceMessage(QStringLiteral("Frequency must be within 0..1000 Hz.")); return false; @@ -150,7 +150,7 @@ class StudioDeviceController : public DeviceController { return true; } - Q_INVOKABLE bool setSignal( + bool setSignal( const QString& signalId, const double magnitude, const double phaseDegrees, @@ -188,7 +188,7 @@ class StudioDeviceController : public DeviceController { return true; } - Q_INVOKABLE bool deployProfile(const QVariantMap& profile) override { + bool deployProfile(const QVariantMap& profile) override { if (!currentFirmwareIdentitySeen()) { emit deviceMessage(QStringLiteral("Firmware update required before profile synchronization.")); return false; From 4bf9a644b35203fb9fcb47b4642a3f0dcd09de01 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:15:28 +0700 Subject: [PATCH 240/272] S8: keep fixture construction on public APIs only --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 1411a68ac..44cc933ea 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -74,8 +74,6 @@ class DeterministicSessionHarness final { session.setProfiles(&profiles); session.setFirmware(&firmware); session.setDevice(&device); - session.started_ = true; - quiesce(session, device); } ~Fixture() { From c82c935388e0ac464c76768ee9afb495b2696d2a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:17:33 +0700 Subject: [PATCH 241/272] S8: build deterministic session harness as separate CI executable --- apps/arstack_studio/CMakeLists.txt | 34 ++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index e1cda0854..19523ea7d 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -17,8 +17,7 @@ set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. ${CMAKE_BINARY_DIR}/ariec61850-core EXCLUDE_FROM_ALL) -qt_add_executable(arstack_studio - src/main.cpp +set(ARSTACK_STUDIO_CORE_SOURCES src/SclProfileModel.cpp src/SclProfileModel.hpp src/DeviceIoWorker.cpp @@ -35,6 +34,21 @@ qt_add_executable(arstack_studio src/SmartSessionController.hpp ) +qt_add_executable(arstack_studio + src/main.cpp + ${ARSTACK_STUDIO_CORE_SOURCES} +) + +# S8 is intentionally a separate, headless executable. It links the same +# production controller/worker sources as Studio but exposes no test command in +# the release application. Friend-only seams let the harness inject deterministic +# transport/session events without introducing a second supervisor implementation. +qt_add_executable(arstack_session_harness + src/DeterministicSessionHarness.cpp + src/DeterministicSessionHarness.hpp + ${ARSTACK_STUDIO_CORE_SOURCES} +) + qt_add_qml_module(arstack_studio URI ARStack.Studio VERSION 1.0 @@ -88,6 +102,12 @@ qt_add_resources(arstack_studio "reference_templates" templates/arstack_4i4v_9-2le_reference.scd ) +qt_add_resources(arstack_session_harness "session_harness_reference_templates" + PREFIX "/arstack" + FILES + templates/arstack_4i4v_9-2le_reference.scd +) + target_link_libraries(arstack_studio PRIVATE Qt6::Core Qt6::Gui @@ -98,10 +118,20 @@ target_link_libraries(arstack_studio PRIVATE ARIEC61850::core ) +target_link_libraries(arstack_session_harness PRIVATE + Qt6::Core + Qt6::SerialPort + ARIEC61850::core +) + target_compile_features(arstack_studio PRIVATE cxx_std_20) +target_compile_features(arstack_session_harness PRIVATE cxx_std_20) target_compile_definitions(arstack_studio PRIVATE ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" ) +target_compile_definitions(arstack_session_harness PRIVATE + ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" +) if(WIN32) set_target_properties(arstack_studio PROPERTIES WIN32_EXECUTABLE TRUE) From d4d329e360bf87b49c0bb727af706c76607b97e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:17:46 +0700 Subject: [PATCH 242/272] S8: add headless harness entry point --- .../arstack_studio/src/DeterministicSessionHarnessMain.cpp | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/arstack_studio/src/DeterministicSessionHarnessMain.cpp diff --git a/apps/arstack_studio/src/DeterministicSessionHarnessMain.cpp b/apps/arstack_studio/src/DeterministicSessionHarnessMain.cpp new file mode 100644 index 000000000..497ee2a66 --- /dev/null +++ b/apps/arstack_studio/src/DeterministicSessionHarnessMain.cpp @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeterministicSessionHarness.hpp" + +int main(int argc, char* argv[]) { + return runDeterministicSessionHarness(argc, argv); +} From 17219f27b3a728d91206fdc032bda8d79929fc83 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:18:08 +0700 Subject: [PATCH 243/272] S8: wire headless harness entry point --- apps/arstack_studio/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 19523ea7d..b53133277 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -44,6 +44,7 @@ qt_add_executable(arstack_studio # the release application. Friend-only seams let the harness inject deterministic # transport/session events without introducing a second supervisor implementation. qt_add_executable(arstack_session_harness + src/DeterministicSessionHarnessMain.cpp src/DeterministicSessionHarness.cpp src/DeterministicSessionHarness.hpp ${ARSTACK_STUDIO_CORE_SOURCES} From 723ce12d98eeceb7f3571f493c8f176360f52ee0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:18:46 +0700 Subject: [PATCH 244/272] S8: gate deterministic session harness in Studio CI --- .github/workflows/arstack-studio-qt.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index e5458a49c..ea962e7cb 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -71,6 +71,7 @@ jobs: qml_root='apps/arstack_studio/qml' device_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*device\.(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' firmware_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*(firmware|FirmwareService)\.(probeTarget|installFirmware)[[:space:]]*\(' + device_meta_pattern='Q_INVOKABLE[[:space:]].*(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' if grep -R -nE "$device_pattern" "$qml_root" --include='*.qml'; then echo 'S7 regression: QML bypasses SmartSessionController with direct DeviceController authority.' @@ -80,7 +81,13 @@ jobs: echo 'S7 regression: QML bypasses SmartSessionController with direct firmware execution.' exit 1 fi - echo 'S7 QML authority boundary: PASS · presentation submits intents through SmartSessionController' + if grep -nE "$device_meta_pattern" \ + apps/arstack_studio/src/DeviceController.hpp \ + apps/arstack_studio/src/StudioDeviceController.hpp; then + echo 'S7 regression: DeviceController mutation leaked back into the QML meta-object.' + exit 1 + fi + echo 'S7 QML authority boundary: PASS · presentation submits intents through SmartSessionController and device mutators are not QML-invokable' - name: Configure shell: bash @@ -94,7 +101,8 @@ jobs: shell: bash run: | set -o pipefail - cmake --build build-arstack-studio --target arstack_studio --parallel 2 \ + cmake --build build-arstack-studio \ + --target arstack_studio arstack_session_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log - name: 4I+4V reference template regression @@ -111,6 +119,13 @@ jobs: ./build-arstack-studio/arstack_studio --check-p0-policy \ 2>&1 | tee /tmp/arstack-studio-p0-policy.log + - name: S8 deterministic session regression + shell: bash + run: | + set -o pipefail + timeout 20s ./build-arstack-studio/arstack_session_harness \ + 2>&1 | tee /tmp/arstack-studio-session-harness.log + - name: Firmware bundle contract regression shell: bash run: | @@ -214,6 +229,7 @@ jobs: /tmp/arstack-studio-build.log /tmp/arstack-studio-reference-template.log /tmp/arstack-studio-p0-policy.log + /tmp/arstack-studio-session-harness.log /tmp/arstack-studio-firmware-contract.log /tmp/arstack-studio-lifecycle.log /tmp/arstack-studio-launch.log From deae0206506cba9b8afcc4bfb3aca6f4bdd60bdf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:23:14 +0700 Subject: [PATCH 245/272] S8: preserve explicit recovery intent across IDENTIFY transaction --- .../src/SmartSessionController.cpp | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index f04e8532b..e35551617 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -725,20 +725,24 @@ void SmartSessionController::reconnectFirmwareSignals() { } void SmartSessionController::refreshRecoveryOfferFromIdentity() { - if (device_ == nullptr || updateRequested_ || setupError_ || device_->deviceVerified() || - device_->identificationState() != DeviceController::IdentificationState::Unidentified) { - if (!updateRequested_ && (device_ == nullptr || - device_->identificationState() != DeviceController::IdentificationState::Unidentified)) { - blankBoardDetected_ = false; - blankBoardPort_.clear(); - manualRecoveryArmed_ = false; - } + if (device_ == nullptr || updateRequested_ || setupError_ || device_->deviceVerified()) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + if (device_ == nullptr || device_->deviceVerified()) manualRecoveryArmed_ = false; + return; + } + + // Preserve explicit recovery intent while the selected port moves through + // CONNECTING/IDENTIFYING. Only the terminal Unidentified state can turn + // that intent into a firmware offer. Automatic discovery always clears the + // flag in startDeviceDiscovery(), so a transient handshake failure alone + // can never imply blank firmware. + if (device_->identificationState() != DeviceController::IdentificationState::Unidentified) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); return; } - // S8 policy: an automatic semantic IDENTIFY timeout means "unknown", never - // "blank firmware". The firmware-install offer is armed only after an - // explicit manual recovery selection (Verify selected port). if (!manualRecoveryArmed_ || recoveryPending_) { blankBoardDetected_ = false; blankBoardPort_.clear(); From 45251747bb8acc9c46a36b73a10536a0d2c892a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:24:36 +0700 Subject: [PATCH 246/272] S8: test manual recovery across IDENTIFY transition --- .../src/DeterministicSessionHarness.cpp | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 44cc933ea..fd0140d73 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -29,7 +29,7 @@ class DeterministicSessionHarness final { {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, {"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()}, - {"manual recovery intent -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, + {"manual recovery intent survives IDENTIFY -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, @@ -169,7 +169,7 @@ class DeterministicSessionHarness final { session.reconcile(); } - static void seedIdentityTimeout(Fixture& fixture, const bool manualRecovery) { + static bool seedIdentityTimeout(Fixture& fixture, const bool manualRecovery) { auto& device = fixture.device; auto& session = fixture.session; @@ -182,21 +182,32 @@ class DeterministicSessionHarness final { device.ports_ = {QStringLiteral("COM7")}; device.recommendedPort_ = QStringLiteral("COM7"); device.portName_ = QStringLiteral("COM7"); - device.connected_ = false; + device.connected_ = true; device.discovering_ = false; device.deviceVerified_ = false; - device.identificationState_ = DeviceController::IdentificationState::Unidentified; - device.identifyAttempts_ = DeviceController::identityMaxAttempts(); + device.identificationState_ = DeviceController::IdentificationState::Identifying; + device.identifyAttempts_ = 1; device.identity_ = {}; device.running_ = false; device.profileArmed_ = false; device.profileDeploying_ = false; + // Drive the real intermediate state first. Explicit recovery intent must + // survive this normal port-open/semantic-IDENTIFY phase. emit device.portsChanged(); - emit device.discoveryChanged(); - emit device.deviceIdentityChanged(); + emit device.connectedChanged(); + emit device.identificationStateChanged(); + session.reconcile(); + const bool intentSurvivedIdentifying = + !manualRecovery || (session.manualRecoveryArmed_ && !session.blankBoardDetected_); + + device.connected_ = false; + device.identificationState_ = DeviceController::IdentificationState::Unidentified; + device.identifyAttempts_ = DeviceController::identityMaxAttempts(); + emit device.connectedChanged(); emit device.identificationStateChanged(); session.reconcile(); + return intentSurvivedIdentifying; } static void simulateDisconnect(Fixture& fixture) { @@ -253,8 +264,7 @@ class DeterministicSessionHarness final { static bool automaticIdentityTimeoutStaysUnidentified() { Fixture fixture; - if (!fixture.profileReady) return false; - seedIdentityTimeout(fixture, false); + if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && !fixture.session.firmwareInstallVisible() && !fixture.session.blankBoardDetected_ && @@ -263,8 +273,7 @@ class DeterministicSessionHarness final { static bool manualRecoveryIntentArmsFirmwareOffer() { Fixture fixture; - if (!fixture.profileReady) return false; - seedIdentityTimeout(fixture, true); + if (!fixture.profileReady || !seedIdentityTimeout(fixture, true)) return false; return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && fixture.session.firmwareInstallVisible() && fixture.session.blankBoardPort_ == QStringLiteral("COM7") && From 311357749b4a1d64a7908d23efb0589c9e12b1d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:39:22 +0700 Subject: [PATCH 247/272] S8B: expose serial transport-loss policy --- apps/arstack_studio/src/DeviceIoWorker.hpp | 23 ++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index f60acd1b2..f776ddab5 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -4,11 +4,11 @@ #include #include #include +#include #include #include -class QSerialPort; class QTimer; // Long-lived serial transport owner for ARStack Studio. @@ -29,6 +29,24 @@ class DeviceIoWorker final : public QObject { [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } + // S8B Windows ownership contract: access/device/resource/read/write errors + // are session-fatal and must release the transport instead of leaving a + // stale COM handle alive. Keep this pure so Windows CI can prove the exact + // Qt error classification without requiring physical USB hardware. + [[nodiscard]] static constexpr bool serialErrorForcesTransportLoss( + const QSerialPort::SerialPortError error) noexcept { + switch (error) { + case QSerialPort::ResourceError: + case QSerialPort::DeviceNotFoundError: + case QSerialPort::PermissionError: + case QSerialPort::ReadError: + case QSerialPort::WriteError: + return true; + default: + return false; + } + } + void enqueueCommands( const QStringList& commands, const bool quiet, @@ -101,6 +119,7 @@ public slots: QStringList ports_; QString recommendedPort_; + QString discoveryStatus_; QStringList probeQueue_; QByteArray pendingRx_; QQueue commandQueue_; @@ -118,4 +137,4 @@ public slots: bool identityConfirmed_{false}; bool writeActive_{false}; bool heartbeatEnabled_{false}; -}; +}; \ No newline at end of file From ca98dd8eb29d7f3070366a20e33dc9592a66408b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:39:42 +0700 Subject: [PATCH 248/272] S8B: keep serial policy patch minimal --- apps/arstack_studio/src/DeviceIoWorker.hpp | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index f776ddab5..df5005cc0 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -119,7 +119,6 @@ public slots: QStringList ports_; QString recommendedPort_; - QString discoveryStatus_; QStringList probeQueue_; QByteArray pendingRx_; QQueue commandQueue_; From 0ed56e8b600bb76c078250f1533ffdb8f06bbb2e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:40:34 +0700 Subject: [PATCH 249/272] S8B: use shared serial transport-loss classification --- apps/arstack_studio/src/DeviceIoWorker.cpp | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 53bd4c479..42a9b3778 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -85,18 +85,11 @@ void DeviceIoWorker::initialize() { connect(serial_, &QSerialPort::errorOccurred, this, [this](const QSerialPort::SerialPortError error) { if (error == QSerialPort::NoError || serial_ == nullptr || !serial_->isOpen()) return; const QString message = serial_->errorString(); - switch (error) { - case QSerialPort::ResourceError: - case QSerialPort::DeviceNotFoundError: - case QSerialPort::PermissionError: - case QSerialPort::ReadError: - case QSerialPort::WriteError: + if (serialErrorForcesTransportLoss(error)) { handleSerialFailure(message); return; - default: - emit transportError(activeGeneration_, message, false); - return; } + emit transportError(activeGeneration_, message, false); }); presenceTimer_->start(); From 2a7751a3eac9f0443a9e89c3a2fd433426c83907 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:41:06 +0700 Subject: [PATCH 250/272] S8B: add Windows ownership integration harness --- .../src/WindowsOwnershipHarness.cpp | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 apps/arstack_studio/src/WindowsOwnershipHarness.cpp diff --git a/apps/arstack_studio/src/WindowsOwnershipHarness.cpp b/apps/arstack_studio/src/WindowsOwnershipHarness.cpp new file mode 100644 index 000000000..e7edb6d32 --- /dev/null +++ b/apps/arstack_studio/src/WindowsOwnershipHarness.cpp @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeviceIoWorker.hpp" +#include "SingleInstanceGuard.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace { +constexpr int kHolderSeconds = 30; +constexpr int kProcessTimeoutMs = 5000; + +QString argumentValue(const QStringList& arguments, const QString& name) { + const qsizetype index = arguments.indexOf(name); + if (index < 0 || index + 1 >= arguments.size()) return {}; + return arguments.at(index + 1).trimmed(); +} + +int runLockHolder(const QString& lockPath) { + if (lockPath.isEmpty()) return 30; + SingleInstanceGuard guard{lockPath}; + if (!guard.tryAcquire()) return 31; + std::fputs("LOCKED\n", stdout); + std::fflush(stdout); + QThread::sleep(kHolderSeconds); + return 0; +} + +int runLockProbe(const QString& lockPath) { + if (lockPath.isEmpty()) return 32; + SingleInstanceGuard guard{lockPath}; + return guard.tryAcquire() ? 0 : SingleInstanceGuard::contentionExitCode(); +} + +bool waitForHolderReady(QProcess& process) { + QByteArray output; + for (int elapsed = 0; elapsed < kProcessTimeoutMs; elapsed += 100) { + if (process.waitForReadyRead(100)) output += process.readAllStandardOutput(); + if (output.contains("LOCKED")) return true; + if (process.state() == QProcess::NotRunning) return false; + } + output += process.readAllStandardOutput(); + return output.contains("LOCKED"); +} + +int runProbeProcess(const QString& program, const QString& lockPath) { + QProcess process; + process.setProgram(program); + process.setArguments({QStringLiteral("--lock-probe"), lockPath}); + process.start(); + if (!process.waitForStarted(kProcessTimeoutMs)) return -100; + if (!process.waitForFinished(kProcessTimeoutMs)) { + process.kill(); + process.waitForFinished(kProcessTimeoutMs); + return -101; + } + return process.exitStatus() == QProcess::NormalExit ? process.exitCode() : -102; +} + +bool serialTransportLossPolicy() { + using Error = QSerialPort::SerialPortError; + return DeviceIoWorker::serialErrorForcesTransportLoss(Error::ResourceError) && + DeviceIoWorker::serialErrorForcesTransportLoss(Error::DeviceNotFoundError) && + DeviceIoWorker::serialErrorForcesTransportLoss(Error::PermissionError) && + DeviceIoWorker::serialErrorForcesTransportLoss(Error::ReadError) && + DeviceIoWorker::serialErrorForcesTransportLoss(Error::WriteError) && + !DeviceIoWorker::serialErrorForcesTransportLoss(Error::NoError) && + !DeviceIoWorker::serialErrorForcesTransportLoss(Error::OpenError) && + !DeviceIoWorker::serialErrorForcesTransportLoss(Error::TimeoutError) && + !DeviceIoWorker::serialErrorForcesTransportLoss(Error::UnknownError); +} + +bool crossProcessOwnershipAndCrashRecovery() { + QTemporaryDir temp; + if (!temp.isValid()) return false; + + const QString lockPath = QDir(temp.path()).filePath(QStringLiteral("windows-ownership.lock")); + const QString program = QCoreApplication::applicationFilePath(); + + QProcess holder; + holder.setProgram(program); + holder.setArguments({QStringLiteral("--lock-holder"), lockPath}); + holder.setProcessChannelMode(QProcess::SeparateChannels); + holder.start(); + if (!holder.waitForStarted(kProcessTimeoutMs) || !waitForHolderReady(holder)) { + if (holder.state() != QProcess::NotRunning) { + holder.kill(); + holder.waitForFinished(kProcessTimeoutMs); + } + return false; + } + + const int contenderExit = runProbeProcess(program, lockPath); + const bool contenderBlocked = contenderExit == SingleInstanceGuard::contentionExitCode(); + + // Model a hard process crash rather than a graceful destructor path. The + // next process must recognize the dead owner and reclaim QLockFile safely. + holder.kill(); + if (!holder.waitForFinished(kProcessTimeoutMs)) return false; + + const int recoveryExit = runProbeProcess(program, lockPath); + const bool staleLockRecovered = recoveryExit == 0; + + return contenderBlocked && staleLockRecovered; +} +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + const QStringList arguments = app.arguments(); + + if (arguments.contains(QStringLiteral("--lock-holder"))) { + return runLockHolder(argumentValue(arguments, QStringLiteral("--lock-holder"))); + } + if (arguments.contains(QStringLiteral("--lock-probe"))) { + return runLockProbe(argumentValue(arguments, QStringLiteral("--lock-probe"))); + } + + const bool serialPolicy = serialTransportLossPolicy(); + const bool processOwnership = crossProcessOwnershipAndCrashRecovery(); + + qInfo().noquote() + << "S8B Windows ownership:" + << (serialPolicy ? "PASS" : "FAIL") + << "· Permission/Resource/DeviceNotFound/Read/Write errors force transport loss"; + qInfo().noquote() + << "S8B Windows ownership:" + << (processOwnership ? "PASS" : "FAIL") + << "· second process blocked with exit 23 and stale lock reclaimed after hard crash"; + + if (!serialPolicy || !processOwnership) { + qCritical().noquote() << "S8B Windows ownership integration harness: FAIL"; + return 22; + } + + qInfo().noquote() + << "S8B Windows ownership integration harness: PASS · OS process lock + crash recovery + serial transport-loss policy locked"; + return 0; +} From cf2b3291298bc555f6139206987d6167b4199ea5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:41:33 +0700 Subject: [PATCH 251/272] S8B: build Windows ownership harness --- apps/arstack_studio/CMakeLists.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index b53133277..7799deb4b 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -50,6 +50,23 @@ qt_add_executable(arstack_session_harness ${ARSTACK_STUDIO_CORE_SOURCES} ) +# S8B adds a Windows-native OS/process ownership gate. It intentionally depends +# only on the production lock class and the pure serial-error policy, so the test +# can prove cross-process QLockFile semantics without pulling a second supervisor +# or test-only serial implementation into the product. +if(WIN32) + qt_add_executable(arstack_windows_ownership_harness + src/WindowsOwnershipHarness.cpp + src/DeviceIoWorker.hpp + src/SingleInstanceGuard.hpp + ) + target_link_libraries(arstack_windows_ownership_harness PRIVATE + Qt6::Core + Qt6::SerialPort + ) + target_compile_features(arstack_windows_ownership_harness PRIVATE cxx_std_20) +endif() + qt_add_qml_module(arstack_studio URI ARStack.Studio VERSION 1.0 From 1e71a9c8506aa2b1d06d70a1366434245ce3d1e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:42:35 +0700 Subject: [PATCH 252/272] S8B: add native Windows ownership integration gate --- .github/workflows/arstack-studio-qt.yml | 76 +++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index ea962e7cb..d63011e23 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -46,6 +46,82 @@ jobs: } Write-Host 'Windows launcher PowerShell parse: PASS' + windows-ownership-integration: + name: S8B Windows ownership integration + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.10.3' + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure native Windows harnesses + shell: powershell + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build native Windows harnesses + shell: powershell + run: | + cmake --build build-arstack-studio --config Release ` + --target arstack_session_harness arstack_windows_ownership_harness --parallel + + - name: S8A deterministic session harness on Windows + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' + $out = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.out.log' + $err = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.err.log' + $process = Start-Process -FilePath $exe -PassThru ` + -RedirectStandardOutput $out -RedirectStandardError $err + if (-not $process.WaitForExit(30000)) { + $process.Kill() + throw 'S8A Windows deterministic harness timed out.' + } + Get-Content $out -ErrorAction SilentlyContinue + Get-Content $err -ErrorAction SilentlyContinue + if ($process.ExitCode -ne 0) { + throw "S8A Windows deterministic harness failed with exit code $($process.ExitCode)." + } + + - name: S8B cross-process ownership and serial-loss harness + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe' + $out = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.out.log' + $err = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.err.log' + $process = Start-Process -FilePath $exe -PassThru ` + -RedirectStandardOutput $out -RedirectStandardError $err + if (-not $process.WaitForExit(30000)) { + $process.Kill() + throw 'S8B Windows ownership harness timed out.' + } + Get-Content $out -ErrorAction SilentlyContinue + Get-Content $err -ErrorAction SilentlyContinue + if ($process.ExitCode -ne 0) { + throw "S8B Windows ownership harness failed with exit code $($process.ExitCode)." + } + + - name: Retain Windows ownership logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-windows-ownership-logs + path: | + ${{ runner.temp }}/arstack-session-windows.out.log + ${{ runner.temp }}/arstack-session-windows.err.log + ${{ runner.temp }}/arstack-windows-ownership.out.log + ${{ runner.temp }}/arstack-windows-ownership.err.log + if-no-files-found: ignore + retention-days: 7 + qt-build-smoke: runs-on: ubuntu-latest container: ubuntu:26.04 From 7c96a60852e9106f1fcc5e350469f8d1b53ff2d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:43:05 +0700 Subject: [PATCH 253/272] S8B: add packaged single-instance ownership probe --- .../packaging/verify-single-instance.ps1 | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 apps/arstack_studio/packaging/verify-single-instance.ps1 diff --git a/apps/arstack_studio/packaging/verify-single-instance.ps1 b/apps/arstack_studio/packaging/verify-single-instance.ps1 new file mode 100644 index 000000000..9679255b8 --- /dev/null +++ b/apps/arstack_studio/packaging/verify-single-instance.ps1 @@ -0,0 +1,60 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Executable, + [int]$ReadyTimeoutMs = 5000, + [int]$ExitTimeoutMs = 10000 +) + +$ErrorActionPreference = 'Stop' +$exe = (Resolve-Path $Executable).Path +$lockPath = Join-Path ([IO.Path]::GetTempPath()) 'arstack-studio-single-instance.lock' +Remove-Item $lockPath -Force -ErrorAction SilentlyContinue + +function Wait-ForExitBounded([System.Diagnostics.Process]$Process, [int]$TimeoutMs, [string]$Label) { + if (-not $Process.WaitForExit($TimeoutMs)) { + try { $Process.Kill() } catch {} + try { $Process.WaitForExit(2000) | Out-Null } catch {} + throw "$Label timed out after $TimeoutMs ms." + } +} + +$first = $null +try { + $first = Start-Process -FilePath $exe -PassThru + $deadline = [DateTime]::UtcNow.AddMilliseconds($ReadyTimeoutMs) + while (-not (Test-Path $lockPath) -and [DateTime]::UtcNow -lt $deadline) { + if ($first.HasExited) { + throw "Primary Studio instance exited before acquiring its process lock (exit=$($first.ExitCode))." + } + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path $lockPath)) { + throw 'Primary Studio instance did not publish the single-instance lock in time.' + } + + $second = Start-Process -FilePath $exe -PassThru + Wait-ForExitBounded $second $ExitTimeoutMs 'Second Studio instance' + if ($second.ExitCode -ne 23) { + throw "Second Studio instance was not blocked by ownership lock; exit=$($second.ExitCode), expected=23." + } + + # Kill the owner deliberately to exercise QLockFile stale-owner recovery, + # matching a hard application crash rather than the graceful destructor path. + $first.Kill() + Wait-ForExitBounded $first $ExitTimeoutMs 'Primary Studio hard-stop' + $first = $null + + $third = Start-Process -FilePath $exe -ArgumentList @('--check-app-lifecycle') -PassThru + Wait-ForExitBounded $third $ExitTimeoutMs 'Post-crash Studio lifecycle' + if ($third.ExitCode -ne 0) { + throw "Studio did not reclaim the stale ownership lock after hard-stop; exit=$($third.ExitCode)." + } + + Write-Host 'S8B packaged ownership: PASS · second instance exit 23 + stale-lock recovery + lifecycle' +} finally { + if ($null -ne $first -and -not $first.HasExited) { + try { $first.Kill() } catch {} + try { $first.WaitForExit(2000) | Out-Null } catch {} + } + Remove-Item $lockPath -Force -ErrorAction SilentlyContinue +} From 942a08c6c6fdfbe7bc45ed3832718699f82e4d6f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:43:53 +0700 Subject: [PATCH 254/272] S8B: gate staged portable and installed Windows ownership --- .github/workflows/arstack-studio-release.yml | 27 +++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 39f6c8327..80a82e8b7 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -75,7 +75,26 @@ jobs: - name: Build native Release shell: powershell - run: cmake --build build-arstack-studio --config Release --target arstack_studio --parallel + run: | + cmake --build build-arstack-studio --config Release ` + --target arstack_studio arstack_session_harness arstack_windows_ownership_harness --parallel + + - name: S8A/S8B native Windows integration gates + shell: powershell + run: | + function Assert-ExitZero([string]$FilePath, [string]$Label, [int]$TimeoutMs = 30000) { + $process = Start-Process -FilePath $FilePath -PassThru + if (-not $process.WaitForExit($TimeoutMs)) { + $process.Kill() + throw "$Label timed out after $TimeoutMs ms" + } + if ($process.ExitCode -ne 0) { + throw "$Label failed with exit code $($process.ExitCode)" + } + } + + Assert-ExitZero (Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe') 'S8A deterministic session harness on Windows' + Assert-ExitZero (Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe') 'S8B Windows ownership harness' - name: Stage Qt application shell: powershell @@ -135,6 +154,7 @@ jobs: Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' Assert-ExitZero $studio @('--check-p0-policy') 'Packaged native policy check' Assert-ExitZero $studio @('--check-app-lifecycle') 'Packaged primary-window shutdown check' + & "$PWD\apps\arstack_studio\packaging\verify-single-instance.ps1" -Executable $studio Assert-ExitZero $espflash @('--version') 'Bundled espflash execution' Assert-ExitZero $espflash @('board-info', '--non-interactive', '--help') 'espflash board-info recovery CLI contract' Assert-ExitZero $espflash @('write-bin', '--chip', 'esp32p4', '--non-interactive', '--help') 'espflash write-bin recovery CLI contract' @@ -189,6 +209,9 @@ jobs: needs: windows-package runs-on: windows-2022 steps: + - name: Checkout ownership probe + uses: actions/checkout@v4 + - name: Download release candidates only uses: actions/download-artifact@v4 with: @@ -235,6 +258,7 @@ jobs: Assert-ExitZero $exe @('--check-firmware-contract') 'Portable firmware contract' Assert-ExitZero $exe @('--check-p0-policy') 'Portable P0 policy' Assert-ExitZero $exe @('--check-app-lifecycle') 'Portable primary-window shutdown check' + & "$PWD\apps\arstack_studio\packaging\verify-single-instance.ps1" -Executable $exe Assert-ExitZero $espflash @('--version') 'Portable espflash' } finally { $env:PATH = $savedPath @@ -266,6 +290,7 @@ jobs: Assert-ExitZero $exe @('--check-firmware-contract') 'Installed firmware contract' Assert-ExitZero $exe @('--check-p0-policy') 'Installed P0 policy' Assert-ExitZero $exe @('--check-app-lifecycle') 'Installed primary-window shutdown check' + & "$PWD\apps\arstack_studio\packaging\verify-single-instance.ps1" -Executable $exe Assert-ExitZero $espflash @('--version') 'Installed espflash' $uninstaller = Join-Path $installDir 'Uninstall.exe' if (Test-Path $uninstaller) { From 1177245cb5d501dc8468e8db73fc4e8fbb83c352 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:47:19 +0700 Subject: [PATCH 255/272] S8B: keep ownership harness free of worker automoc --- apps/arstack_studio/CMakeLists.txt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 7799deb4b..bb6e45211 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -51,14 +51,13 @@ qt_add_executable(arstack_session_harness ) # S8B adds a Windows-native OS/process ownership gate. It intentionally depends -# only on the production lock class and the pure serial-error policy, so the test -# can prove cross-process QLockFile semantics without pulling a second supervisor -# or test-only serial implementation into the product. +# only on the production lock class and the pure serial-error policy. Do not add +# DeviceIoWorker.hpp as a target source: AUTOMOC would then generate metaobject +# calls for worker slots and pull the full serial worker into this lightweight +# process-ownership harness. if(WIN32) qt_add_executable(arstack_windows_ownership_harness src/WindowsOwnershipHarness.cpp - src/DeviceIoWorker.hpp - src/SingleInstanceGuard.hpp ) target_link_libraries(arstack_windows_ownership_harness PRIVATE Qt6::Core From 01e0befddf1f90adbccaf5316583eea2235f4dcf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:52:56 +0700 Subject: [PATCH 256/272] S8B: stabilize packaged process exit-code capture --- .../packaging/verify-single-instance.ps1 | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/packaging/verify-single-instance.ps1 b/apps/arstack_studio/packaging/verify-single-instance.ps1 index 9679255b8..35a3c6784 100644 --- a/apps/arstack_studio/packaging/verify-single-instance.ps1 +++ b/apps/arstack_studio/packaging/verify-single-instance.ps1 @@ -16,6 +16,12 @@ function Wait-ForExitBounded([System.Diagnostics.Process]$Process, [int]$Timeout try { $Process.WaitForExit(2000) | Out-Null } catch {} throw "$Label timed out after $TimeoutMs ms." } + + # PowerShell/.NET may leave ExitCode unmaterialized after the timed overload, + # especially for quickly exiting GUI-subsystem processes. Complete the wait + # and refresh the process snapshot before callers inspect ExitCode. + $Process.WaitForExit() + $Process.Refresh() } $first = $null @@ -24,6 +30,7 @@ try { $deadline = [DateTime]::UtcNow.AddMilliseconds($ReadyTimeoutMs) while (-not (Test-Path $lockPath) -and [DateTime]::UtcNow -lt $deadline) { if ($first.HasExited) { + $first.Refresh() throw "Primary Studio instance exited before acquiring its process lock (exit=$($first.ExitCode))." } Start-Sleep -Milliseconds 100 @@ -54,7 +61,12 @@ try { } finally { if ($null -ne $first -and -not $first.HasExited) { try { $first.Kill() } catch {} - try { $first.WaitForExit(2000) | Out-Null } catch {} + try { + if ($first.WaitForExit(2000)) { + $first.WaitForExit() + $first.Refresh() + } + } catch {} } Remove-Item $lockPath -Force -ErrorAction SilentlyContinue } From 538b3b4a42d269fd51abc8763b09014517de5739 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:53:35 +0700 Subject: [PATCH 257/272] S8B: fix Windows harness exit-code capture --- .github/workflows/arstack-studio-qt.yml | 44 ++++++++++++++++--------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index d63011e23..d52d0da2f 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -30,21 +30,27 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Parse Windows launcher + - name: Parse Windows PowerShell entrypoints shell: powershell run: | - $path = Resolve-Path '.\apps\arstack_studio\run-windows.ps1' - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile( - $path, - [ref]$tokens, - [ref]$errors) | Out-Null - if ($errors.Count -gt 0) { - $errors | Format-List * - throw "Windows launcher contains $($errors.Count) PowerShell parser error(s)." + $paths = @( + '.\apps\arstack_studio\run-windows.ps1', + '.\apps\arstack_studio\packaging\verify-single-instance.ps1' + ) + foreach ($candidate in $paths) { + $path = Resolve-Path $candidate + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $path, + [ref]$tokens, + [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $errors | Format-List * + throw "$candidate contains $($errors.Count) PowerShell parser error(s)." + } } - Write-Host 'Windows launcher PowerShell parse: PASS' + Write-Host 'Windows PowerShell parse: PASS · launcher + S8B packaged ownership probe' windows-ownership-integration: name: S8B Windows ownership integration @@ -85,10 +91,13 @@ jobs: $process.Kill() throw 'S8A Windows deterministic harness timed out.' } + $process.WaitForExit() + $process.Refresh() + $exitCode = $process.ExitCode Get-Content $out -ErrorAction SilentlyContinue Get-Content $err -ErrorAction SilentlyContinue - if ($process.ExitCode -ne 0) { - throw "S8A Windows deterministic harness failed with exit code $($process.ExitCode)." + if ($exitCode -ne 0) { + throw "S8A Windows deterministic harness failed with exit code $exitCode." } - name: S8B cross-process ownership and serial-loss harness @@ -103,10 +112,13 @@ jobs: $process.Kill() throw 'S8B Windows ownership harness timed out.' } + $process.WaitForExit() + $process.Refresh() + $exitCode = $process.ExitCode Get-Content $out -ErrorAction SilentlyContinue Get-Content $err -ErrorAction SilentlyContinue - if ($process.ExitCode -ne 0) { - throw "S8B Windows ownership harness failed with exit code $($process.ExitCode)." + if ($exitCode -ne 0) { + throw "S8B Windows ownership harness failed with exit code $exitCode." } - name: Retain Windows ownership logs From 558a1e346f16b0534dc147eb6fcbd47e0a613b18 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 21:54:19 +0700 Subject: [PATCH 258/272] S8B: stabilize release Windows process exit capture --- .github/workflows/arstack-studio-release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 80a82e8b7..33bf0fd9f 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -88,8 +88,11 @@ jobs: $process.Kill() throw "$Label timed out after $TimeoutMs ms" } - if ($process.ExitCode -ne 0) { - throw "$Label failed with exit code $($process.ExitCode)" + $process.WaitForExit() + $process.Refresh() + $exitCode = $process.ExitCode + if ($exitCode -ne 0) { + throw "$Label failed with exit code $exitCode" } } From db38553252e973a02a271a96dc69ecd8937f6f35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 22:01:42 +0700 Subject: [PATCH 259/272] S8B: use tracked .NET processes for packaged ownership --- .../packaging/verify-single-instance.ps1 | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/arstack_studio/packaging/verify-single-instance.ps1 b/apps/arstack_studio/packaging/verify-single-instance.ps1 index 35a3c6784..a85c5df3f 100644 --- a/apps/arstack_studio/packaging/verify-single-instance.ps1 +++ b/apps/arstack_studio/packaging/verify-single-instance.ps1 @@ -10,27 +10,35 @@ $exe = (Resolve-Path $Executable).Path $lockPath = Join-Path ([IO.Path]::GetTempPath()) 'arstack-studio-single-instance.lock' Remove-Item $lockPath -Force -ErrorAction SilentlyContinue +function Start-TrackedProcess([string]$FilePath, [string]$Arguments = '') { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $FilePath + $startInfo.Arguments = $Arguments + $startInfo.UseShellExecute = $false + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "Failed to start $FilePath" + } + return $process +} + function Wait-ForExitBounded([System.Diagnostics.Process]$Process, [int]$TimeoutMs, [string]$Label) { if (-not $Process.WaitForExit($TimeoutMs)) { try { $Process.Kill() } catch {} try { $Process.WaitForExit(2000) | Out-Null } catch {} throw "$Label timed out after $TimeoutMs ms." } - - # PowerShell/.NET may leave ExitCode unmaterialized after the timed overload, - # especially for quickly exiting GUI-subsystem processes. Complete the wait - # and refresh the process snapshot before callers inspect ExitCode. $Process.WaitForExit() - $Process.Refresh() } $first = $null try { - $first = Start-Process -FilePath $exe -PassThru + $first = Start-TrackedProcess $exe $deadline = [DateTime]::UtcNow.AddMilliseconds($ReadyTimeoutMs) while (-not (Test-Path $lockPath) -and [DateTime]::UtcNow -lt $deadline) { if ($first.HasExited) { - $first.Refresh() throw "Primary Studio instance exited before acquiring its process lock (exit=$($first.ExitCode))." } Start-Sleep -Milliseconds 100 @@ -39,7 +47,7 @@ try { throw 'Primary Studio instance did not publish the single-instance lock in time.' } - $second = Start-Process -FilePath $exe -PassThru + $second = Start-TrackedProcess $exe Wait-ForExitBounded $second $ExitTimeoutMs 'Second Studio instance' if ($second.ExitCode -ne 23) { throw "Second Studio instance was not blocked by ownership lock; exit=$($second.ExitCode), expected=23." @@ -51,7 +59,7 @@ try { Wait-ForExitBounded $first $ExitTimeoutMs 'Primary Studio hard-stop' $first = $null - $third = Start-Process -FilePath $exe -ArgumentList @('--check-app-lifecycle') -PassThru + $third = Start-TrackedProcess $exe '--check-app-lifecycle' Wait-ForExitBounded $third $ExitTimeoutMs 'Post-crash Studio lifecycle' if ($third.ExitCode -ne 0) { throw "Studio did not reclaim the stale ownership lock after hard-stop; exit=$($third.ExitCode)." @@ -61,12 +69,7 @@ try { } finally { if ($null -ne $first -and -not $first.HasExited) { try { $first.Kill() } catch {} - try { - if ($first.WaitForExit(2000)) { - $first.WaitForExit() - $first.Refresh() - } - } catch {} + try { $first.WaitForExit(2000) | Out-Null } catch {} } Remove-Item $lockPath -Force -ErrorAction SilentlyContinue } From ba15573e972c73f2b51faba965b4d0eaa72b4dc5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 22:02:26 +0700 Subject: [PATCH 260/272] S8B: use direct Windows harness exit codes --- .github/workflows/arstack-studio-qt.yml | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index d52d0da2f..e13de6a97 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -80,20 +80,14 @@ jobs: --target arstack_session_harness arstack_windows_ownership_harness --parallel - name: S8A deterministic session harness on Windows + timeout-minutes: 1 shell: powershell run: | $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' $out = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.out.log' $err = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.err.log' - $process = Start-Process -FilePath $exe -PassThru ` - -RedirectStandardOutput $out -RedirectStandardError $err - if (-not $process.WaitForExit(30000)) { - $process.Kill() - throw 'S8A Windows deterministic harness timed out.' - } - $process.WaitForExit() - $process.Refresh() - $exitCode = $process.ExitCode + & $exe 1> $out 2> $err + $exitCode = $LASTEXITCODE Get-Content $out -ErrorAction SilentlyContinue Get-Content $err -ErrorAction SilentlyContinue if ($exitCode -ne 0) { @@ -101,20 +95,14 @@ jobs: } - name: S8B cross-process ownership and serial-loss harness + timeout-minutes: 1 shell: powershell run: | $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe' $out = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.out.log' $err = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.err.log' - $process = Start-Process -FilePath $exe -PassThru ` - -RedirectStandardOutput $out -RedirectStandardError $err - if (-not $process.WaitForExit(30000)) { - $process.Kill() - throw 'S8B Windows ownership harness timed out.' - } - $process.WaitForExit() - $process.Refresh() - $exitCode = $process.ExitCode + & $exe 1> $out 2> $err + $exitCode = $LASTEXITCODE Get-Content $out -ErrorAction SilentlyContinue Get-Content $err -ErrorAction SilentlyContinue if ($exitCode -ne 0) { From d600b0b9f1aa3e1db08cf2a2ae48cac174a9318e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 22:03:13 +0700 Subject: [PATCH 261/272] S8B: use direct native harness exit codes in release gate --- .github/workflows/arstack-studio-release.yml | 26 +++++++++----------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 33bf0fd9f..9fbe5dd92 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -80,24 +80,22 @@ jobs: --target arstack_studio arstack_session_harness arstack_windows_ownership_harness --parallel - name: S8A/S8B native Windows integration gates + timeout-minutes: 2 shell: powershell run: | - function Assert-ExitZero([string]$FilePath, [string]$Label, [int]$TimeoutMs = 30000) { - $process = Start-Process -FilePath $FilePath -PassThru - if (-not $process.WaitForExit($TimeoutMs)) { - $process.Kill() - throw "$Label timed out after $TimeoutMs ms" - } - $process.WaitForExit() - $process.Refresh() - $exitCode = $process.ExitCode - if ($exitCode -ne 0) { - throw "$Label failed with exit code $exitCode" - } + $sessionHarness = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' + & $sessionHarness + $sessionExit = $LASTEXITCODE + if ($sessionExit -ne 0) { + throw "S8A deterministic session harness on Windows failed with exit code $sessionExit" } - Assert-ExitZero (Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe') 'S8A deterministic session harness on Windows' - Assert-ExitZero (Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe') 'S8B Windows ownership harness' + $ownershipHarness = Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe' + & $ownershipHarness + $ownershipExit = $LASTEXITCODE + if ($ownershipExit -ne 0) { + throw "S8B Windows ownership harness failed with exit code $ownershipExit" + } - name: Stage Qt application shell: powershell From 840c5760cce23da4e5ceae9dbe6f21666a7b611d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 22:38:14 +0700 Subject: [PATCH 262/272] RC2: add machine-readable physical acceptance evidence gate --- apps/arstack_studio/src/Rc2Acceptance.cpp | 256 ++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 apps/arstack_studio/src/Rc2Acceptance.cpp diff --git a/apps/arstack_studio/src/Rc2Acceptance.cpp b/apps/arstack_studio/src/Rc2Acceptance.cpp new file mode 100644 index 000000000..b0ef46959 --- /dev/null +++ b/apps/arstack_studio/src/Rc2Acceptance.cpp @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include + +namespace { +struct Verdict final { + bool pass{false}; + QStringList failures; +}; + +[[nodiscard]] QJsonObject objectAt(const QJsonObject& root, const char* key) { + return root.value(QLatin1String(key)).toObject(); +} + +[[nodiscard]] qint64 integerAt(const QJsonObject& object, const char* key) { + return object.value(QLatin1String(key)).toVariant().toLongLong(); +} + +[[nodiscard]] bool boolAt(const QJsonObject& object, const char* key) { + return object.value(QLatin1String(key)).toBool(false); +} + +[[nodiscard]] QString stringAt(const QJsonObject& object, const char* key) { + return object.value(QLatin1String(key)).toString(); +} + +void require(Verdict& verdict, const bool condition, const QString& failure) { + if (!condition) verdict.failures.push_back(failure); +} + +[[nodiscard]] Verdict evaluate(const QJsonObject& root, const QString& expectedSha = {}) { + Verdict verdict; + const auto profile = objectAt(root, "profile"); + const auto telemetry = objectAt(root, "telemetry"); + const auto operations = objectAt(root, "operations"); + const auto capture = objectAt(root, "capture"); + const auto operatorState = objectAt(root, "operator"); + + require(verdict, stringAt(root, "schema") == QStringLiteral("arstack.studio.rc2.v1"), + QStringLiteral("schema must be arstack.studio.rc2.v1")); + + const QString candidateSha = stringAt(root, "candidateSha").trimmed().toLower(); + static const QRegularExpression sha40{QStringLiteral("^[0-9a-f]{40}$")}; + require(verdict, sha40.match(candidateSha).hasMatch(), + QStringLiteral("candidateSha must be a full 40-character Git commit SHA")); + if (!expectedSha.isEmpty()) { + require(verdict, candidateSha == expectedSha.trimmed().toLower(), + QStringLiteral("candidateSha does not match the expected RC head")); + } + + require(verdict, integerAt(profile, "channels") == 8, + QStringLiteral("profile must remain 4I+4V / 8 channels")); + require(verdict, integerAt(profile, "publisherRate") == 4000, + QStringLiteral("publisher rate must be exactly 4000 fps")); + require(verdict, integerAt(profile, "asduCount") == 1, + QStringLiteral("profile must use exactly 1 ASDU")); + require(verdict, integerAt(profile, "payloadBytes") == 64, + QStringLiteral("sample payload must be exactly 64 bytes")); + require(verdict, integerAt(profile, "channelLeafCount") == 16, + QStringLiteral("profile must contain exactly 16 ordered FCDA leaves")); + + require(verdict, integerAt(telemetry, "durationSeconds") >= 3600, + QStringLiteral("sustained telemetry soak must be at least 3600 seconds")); + require(verdict, integerAt(telemetry, "fpsMin") >= 3999 && integerAt(telemetry, "fpsMax") <= 4001, + QStringLiteral("observed one-second fps windows must stay inside 3999..4001")); + require(verdict, integerAt(telemetry, "missed") == 0, + QStringLiteral("missed sample slots must remain zero")); + require(verdict, integerAt(telemetry, "txFailures") == 0, + QStringLiteral("canonical TX failures must remain zero")); + require(verdict, integerAt(telemetry, "healthReconnects") == 0, + QStringLiteral("control health must not reconnect during the retained run")); + require(verdict, integerAt(telemetry, "automaticStarts") == 0, + QStringLiteral("no automatic Start is permitted during RC2")); + + require(verdict, boolAt(operations, "deployReady"), + QStringLiteral("4I+4V deploy must reach READY")); + require(verdict, integerAt(operations, "startStopCycles") >= 10, + QStringLiteral("at least 10 clean Start/Stop cycles are required")); + require(verdict, boolAt(operations, "zero"), + QStringLiteral("Zero operation must pass")); + require(verdict, boolAt(operations, "liveMagnitude"), + QStringLiteral("live magnitude edit must pass")); + require(verdict, boolAt(operations, "livePhase"), + QStringLiteral("live phase edit must pass")); + require(verdict, boolAt(operations, "liveFrequency"), + QStringLiteral("live frequency edit must pass")); + require(verdict, boolAt(operations, "liveQuality"), + QStringLiteral("live Quality edit must pass")); + require(verdict, boolAt(operations, "ctSaturation"), + QStringLiteral("CT saturation live control must pass")); + require(verdict, boolAt(operations, "profileResync"), + QStringLiteral("profile re-sync must return to READY")); + require(verdict, boolAt(operations, "signalGenerationAdvanced"), + QStringLiteral("live edits must advance signal generation")); + require(verdict, !boolAt(operations, "smpCntRestartOnLiveEdit"), + QStringLiteral("live edits must not restart smpCnt")); + + require(verdict, integerAt(capture, "durationSeconds") >= 10, + QStringLiteral("independent packet capture window must be at least 10 seconds")); + require(verdict, boolAt(capture, "destinationMacMatches"), + QStringLiteral("capture destination MAC must match the deployed profile")); + require(verdict, boolAt(capture, "appidMatches"), + QStringLiteral("capture APPID must match the deployed profile")); + require(verdict, boolAt(capture, "vlanPcpMatches"), + QStringLiteral("capture VLAN/PCP must match the deployed profile")); + require(verdict, boolAt(capture, "svIdMatches"), + QStringLiteral("capture svID must match the deployed profile")); + require(verdict, boolAt(capture, "confRevMatches"), + QStringLiteral("capture confRev must match the deployed profile")); + require(verdict, boolAt(capture, "asduCountMatches"), + QStringLiteral("capture must prove exactly 1 ASDU")); + require(verdict, boolAt(capture, "payloadBytesMatches"), + QStringLiteral("capture must prove a 64-byte sample payload")); + require(verdict, boolAt(capture, "smpCntContinuous"), + QStringLiteral("capture must prove continuous smpCnt progression")); + require(verdict, boolAt(capture, "counterWrapObserved"), + QStringLiteral("capture must observe the 3999 -> 0 counter wrap")); + require(verdict, integerAt(capture, "smpSynchAdvertised") == 0, + QStringLiteral("RC2 must retain honest smpSynch=0 advertisement")); + + require(verdict, boolAt(operatorState, "uiResponsive"), + QStringLiteral("Studio UI must remain responsive during the retained run")); + require(verdict, !boolAt(operatorState, "orphanWorkerObserved"), + QStringLiteral("no orphan worker/process may remain after shutdown")); + + verdict.pass = verdict.failures.isEmpty(); + return verdict; +} + +[[nodiscard]] QJsonObject passingFixture() { + return { + {QStringLiteral("schema"), QStringLiteral("arstack.studio.rc2.v1")}, + {QStringLiteral("candidateSha"), QStringLiteral("0123456789abcdef0123456789abcdef01234567")}, + {QStringLiteral("profile"), QJsonObject{ + {QStringLiteral("channels"), 8}, + {QStringLiteral("publisherRate"), 4000}, + {QStringLiteral("asduCount"), 1}, + {QStringLiteral("payloadBytes"), 64}, + {QStringLiteral("channelLeafCount"), 16}}}, + {QStringLiteral("telemetry"), QJsonObject{ + {QStringLiteral("durationSeconds"), 3600}, + {QStringLiteral("fpsMin"), 4000}, + {QStringLiteral("fpsMax"), 4000}, + {QStringLiteral("missed"), 0}, + {QStringLiteral("txFailures"), 0}, + {QStringLiteral("healthReconnects"), 0}, + {QStringLiteral("automaticStarts"), 0}}}, + {QStringLiteral("operations"), QJsonObject{ + {QStringLiteral("deployReady"), true}, + {QStringLiteral("startStopCycles"), 10}, + {QStringLiteral("zero"), true}, + {QStringLiteral("liveMagnitude"), true}, + {QStringLiteral("livePhase"), true}, + {QStringLiteral("liveFrequency"), true}, + {QStringLiteral("liveQuality"), true}, + {QStringLiteral("ctSaturation"), true}, + {QStringLiteral("profileResync"), true}, + {QStringLiteral("signalGenerationAdvanced"), true}, + {QStringLiteral("smpCntRestartOnLiveEdit"), false}}}, + {QStringLiteral("capture"), QJsonObject{ + {QStringLiteral("durationSeconds"), 10}, + {QStringLiteral("destinationMacMatches"), true}, + {QStringLiteral("appidMatches"), true}, + {QStringLiteral("vlanPcpMatches"), true}, + {QStringLiteral("svIdMatches"), true}, + {QStringLiteral("confRevMatches"), true}, + {QStringLiteral("asduCountMatches"), true}, + {QStringLiteral("payloadBytesMatches"), true}, + {QStringLiteral("smpCntContinuous"), true}, + {QStringLiteral("counterWrapObserved"), true}, + {QStringLiteral("smpSynchAdvertised"), 0}}}, + {QStringLiteral("operator"), QJsonObject{ + {QStringLiteral("uiResponsive"), true}, + {QStringLiteral("orphanWorkerObserved"), false}}} + }; +} + +[[nodiscard]] bool rejectsMutation(QJsonObject fixture, const QString& section, + const QString& key, const QJsonValue& value) { + QJsonObject nested = fixture.value(section).toObject(); + nested.insert(key, value); + fixture.insert(section, nested); + return !evaluate(fixture).pass; +} + +[[nodiscard]] bool selfTest() { + const QJsonObject valid = passingFixture(); + if (!evaluate(valid, QStringLiteral("0123456789abcdef0123456789abcdef01234567")).pass) return false; + + return rejectsMutation(valid, QStringLiteral("telemetry"), QStringLiteral("durationSeconds"), 3599) && + rejectsMutation(valid, QStringLiteral("telemetry"), QStringLiteral("txFailures"), 1) && + rejectsMutation(valid, QStringLiteral("telemetry"), QStringLiteral("missed"), 1) && + rejectsMutation(valid, QStringLiteral("operations"), QStringLiteral("smpCntRestartOnLiveEdit"), true) && + rejectsMutation(valid, QStringLiteral("capture"), QStringLiteral("smpCntContinuous"), false) && + rejectsMutation(valid, QStringLiteral("capture"), QStringLiteral("payloadBytesMatches"), false) && + rejectsMutation(valid, QStringLiteral("capture"), QStringLiteral("smpSynchAdvertised"), 1) && + rejectsMutation(valid, QStringLiteral("operator"), QStringLiteral("uiResponsive"), false); +} + +[[nodiscard]] QString argumentValue(int argc, char* argv[], const QString& name) { + for (int index = 1; index + 1 < argc; ++index) { + if (QString::fromLocal8Bit(argv[index]) == name) return QString::fromLocal8Bit(argv[index + 1]); + } + return {}; +} +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + const QStringList arguments = app.arguments(); + + if (arguments.contains(QStringLiteral("--self-test"))) { + if (!selfTest()) { + qCritical().noquote() << "RC2 acceptance policy self-test: FAIL"; + return 2; + } + qInfo().noquote() << "RC2 acceptance policy self-test: PASS · positive fixture + fail-closed negative mutations"; + return 0; + } + + const QString evidencePath = argumentValue(argc, argv, QStringLiteral("--evidence")); + const QString expectedSha = argumentValue(argc, argv, QStringLiteral("--expect-sha")); + if (evidencePath.isEmpty()) { + qCritical().noquote() << "Usage: arstack_rc2_acceptance --evidence [--expect-sha <40-char SHA>]"; + return 3; + } + + QFile file{evidencePath}; + if (!file.open(QIODevice::ReadOnly)) { + qCritical().noquote() << "RC2 evidence could not be opened:" << evidencePath; + return 4; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + qCritical().noquote() << "RC2 evidence JSON is invalid:" << parseError.errorString(); + return 5; + } + + const Verdict verdict = evaluate(document.object(), expectedSha); + if (!verdict.pass) { + qCritical().noquote() << "RC2 physical acceptance: FAIL"; + for (const QString& failure : verdict.failures) qCritical().noquote() << " -" << failure; + return 6; + } + + qInfo().noquote() + << "RC2 physical acceptance: PASS · 4I+4V / 4000 fps / zero missed+TXfail / live controls / independent capture / smpSynch=0 truth boundary"; + return 0; +} From 34026288423d0700e39b6b675383cf7313de5e41 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 22:38:44 +0700 Subject: [PATCH 263/272] RC2: wire physical acceptance evidence target --- apps/arstack_studio/CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index bb6e45211..05f47d343 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -50,6 +50,15 @@ qt_add_executable(arstack_session_harness ${ARSTACK_STUDIO_CORE_SOURCES} ) +# RC2 is a fail-closed evidence evaluator, not a hardware simulator. CI self-tests +# its acceptance policy; a physical run feeds the same executable a machine-readable +# evidence JSON collected from Studio telemetry and an independent SV capture. +qt_add_executable(arstack_rc2_acceptance + src/Rc2Acceptance.cpp +) +target_link_libraries(arstack_rc2_acceptance PRIVATE Qt6::Core) +target_compile_features(arstack_rc2_acceptance PRIVATE cxx_std_20) + # S8B adds a Windows-native OS/process ownership gate. It intentionally depends # only on the production lock class and the pure serial-error policy. Do not add # DeviceIoWorker.hpp as a target source: AUTOMOC would then generate metaobject From cd676caac7118c09ae82f1739b347877ab67552e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 22:38:54 +0700 Subject: [PATCH 264/272] RC2: add physical evidence template --- .../rc2/rc2-evidence-template.json | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/arstack_studio/rc2/rc2-evidence-template.json diff --git a/apps/arstack_studio/rc2/rc2-evidence-template.json b/apps/arstack_studio/rc2/rc2-evidence-template.json new file mode 100644 index 000000000..25709dafe --- /dev/null +++ b/apps/arstack_studio/rc2/rc2-evidence-template.json @@ -0,0 +1,50 @@ +{ + "schema": "arstack.studio.rc2.v1", + "candidateSha": "REPLACE_WITH_40_CHAR_GIT_SHA", + "profile": { + "channels": 8, + "publisherRate": 4000, + "asduCount": 1, + "payloadBytes": 64, + "channelLeafCount": 16 + }, + "telemetry": { + "durationSeconds": 3600, + "fpsMin": 4000, + "fpsMax": 4000, + "missed": 0, + "txFailures": 0, + "healthReconnects": 0, + "automaticStarts": 0 + }, + "operations": { + "deployReady": false, + "startStopCycles": 0, + "zero": false, + "liveMagnitude": false, + "livePhase": false, + "liveFrequency": false, + "liveQuality": false, + "ctSaturation": false, + "profileResync": false, + "signalGenerationAdvanced": false, + "smpCntRestartOnLiveEdit": true + }, + "capture": { + "durationSeconds": 10, + "destinationMacMatches": false, + "appidMatches": false, + "vlanPcpMatches": false, + "svIdMatches": false, + "confRevMatches": false, + "asduCountMatches": false, + "payloadBytesMatches": false, + "smpCntContinuous": false, + "counterWrapObserved": false, + "smpSynchAdvertised": 0 + }, + "operator": { + "uiResponsive": false, + "orphanWorkerObserved": true + } +} From c4aed8c5cd7f5a1434944ff26cde11906df7fd9c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 22:39:15 +0700 Subject: [PATCH 265/272] RC2: document full functional and 4000 fps acceptance gate --- docs/SMV_STUDIO_RC2_ACCEPTANCE.md | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/SMV_STUDIO_RC2_ACCEPTANCE.md diff --git a/docs/SMV_STUDIO_RC2_ACCEPTANCE.md b/docs/SMV_STUDIO_RC2_ACCEPTANCE.md new file mode 100644 index 000000000..3d9edfbd9 --- /dev/null +++ b/docs/SMV_STUDIO_RC2_ACCEPTANCE.md @@ -0,0 +1,62 @@ +# ARStack Studio RC2 physical acceptance + +RC2 is the final functional/performance acceptance gate before merge/release. It does **not** replace RC1 USB/session checks and it cannot be completed by synthetic CI alone. + +## Candidate freeze + +Run RC2 against one exact PR head. Record the full 40-character SHA in the evidence JSON and validate with the same SHA. Any production-code change invalidates that physical result and requires a new candidate. + +## Required sequence + +1. Launch the exact Windows candidate and verify the current ESP32-P4 injector reaches READY without a firmware prompt. +2. Load the bundled 4I+4V Quick Start profile, Deploy, reach READY, then Start. +3. Complete at least 10 clean Start/Stop cycles and one Zero operation. +4. While RUNNING, exercise live magnitude, phase, frequency, Quality, CT saturation, and profile re-sync. Confirm signal generation advances and `smpCnt` does not restart because of a live edit. +5. Start a retained run of at least 3600 seconds. The whole run must remain within the 4000-fps contract, with zero missed sample slots, zero canonical TX failures, zero health-triggered reconnects, and zero automatic Starts. +6. During the retained run, keep Studio usable and responsive; no worker/process may remain orphaned after shutdown. +7. Independently capture at least 10 seconds of SV traffic. The capture must prove destination MAC, APPID, VLAN/PCP, svID, confRev, exactly 1 ASDU, 64-byte sample payload, continuous `smpCnt`, and at least one `3999 -> 0` wrap. +8. `smpSynch` must remain advertised as `0` for this RC2 gate. Any future synchronized claim requires a separate measured-clock evidence package and must not be inferred from configuration alone. + +## Telemetry acceptance + +The evidence evaluator requires: + +- `durationSeconds >= 3600` +- profile rate exactly `4000` +- one-second observed fps windows within `3999..4001` +- `missed = 0` +- `txFailures = 0` +- `healthReconnects = 0` +- `automaticStarts = 0` + +The narrow 3999..4001 observation envelope only accounts for one-second reporting-window boundary effects. It does not relax the configured 4000-fps publisher contract; zero missed slots and independent counter continuity remain mandatory. + +## Machine-readable evidence + +Copy: + +`apps/arstack_studio/rc2/rc2-evidence-template.json` + +Fill it only from the physical run and independent capture. Do not mark an item true because CI simulated it. + +Validate on the exact candidate build: + +```text +arstack_rc2_acceptance --evidence rc2-evidence.json --expect-sha <40-char-pr-head-sha> +``` + +Exit code `0` is RC2 policy PASS. Any missing/false/unsafe field is fail-closed and prints the rejected acceptance conditions. + +## CI boundary + +CI only runs: + +```text +arstack_rc2_acceptance --self-test +``` + +The self-test proves that a complete positive fixture passes and that unsafe mutations such as shortened soak, missed slots, TX failure, `smpCnt` restart, broken capture continuity, wrong payload evidence, non-zero `smpSynch`, or an unresponsive UI are rejected. CI success is therefore necessary but never sufficient for RC2 physical acceptance. + +## Merge boundary + +PR #79 remains unmerged until both RC1 and RC2 physical evidence are complete. The final acceptance issue must contain the candidate SHA, evidence JSON result, retained-run telemetry summary, and independent capture reference. From ed46f3bed73d398d71be2eacf34c4995325c3cad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 14 Sep 2026 22:39:57 +0700 Subject: [PATCH 266/272] RC2: add Windows physical evidence policy CI gate --- .../workflows/arstack-studio-rc2-policy.yml | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/arstack-studio-rc2-policy.yml diff --git a/.github/workflows/arstack-studio-rc2-policy.yml b/.github/workflows/arstack-studio-rc2-policy.yml new file mode 100644 index 000000000..9a16d418f --- /dev/null +++ b/.github/workflows/arstack-studio-rc2-policy.yml @@ -0,0 +1,109 @@ +name: ARStack Studio RC2 Acceptance Policy + +on: + pull_request: + paths: + - 'apps/arstack_studio/src/Rc2Acceptance.cpp' + - 'apps/arstack_studio/rc2/**' + - 'apps/arstack_studio/CMakeLists.txt' + - 'docs/SMV_STUDIO_RC2_ACCEPTANCE.md' + - '.github/workflows/arstack-studio-rc2-policy.yml' + push: + paths: + - 'apps/arstack_studio/src/Rc2Acceptance.cpp' + - 'apps/arstack_studio/rc2/**' + - 'apps/arstack_studio/CMakeLists.txt' + - 'docs/SMV_STUDIO_RC2_ACCEPTANCE.md' + - '.github/workflows/arstack-studio-rc2-policy.yml' + +permissions: + contents: read + +concurrency: + group: arstack-studio-rc2-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + windows-policy: + name: RC2 Windows evidence gate + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.10.3' + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure + shell: powershell + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build RC2 evidence evaluator + shell: powershell + run: | + cmake --build build-arstack-studio --config Release --target arstack_rc2_acceptance --parallel + + - name: RC2 fail-closed policy self-test + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_rc2_acceptance.exe' + & $exe --self-test + if ($LASTEXITCODE -ne 0) { + throw "RC2 acceptance policy self-test failed with exit code $LASTEXITCODE" + } + + - name: Stage physical QA evaluator + shell: powershell + run: | + $stage = Join-Path $PWD 'dist\RC2-Acceptance' + New-Item -ItemType Directory -Force $stage | Out-Null + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_rc2_acceptance.exe' + Copy-Item $exe (Join-Path $stage 'arstack_rc2_acceptance.exe') + Copy-Item 'apps\arstack_studio\rc2\rc2-evidence-template.json' $stage + Copy-Item 'docs\SMV_STUDIO_RC2_ACCEPTANCE.md' $stage + windeployqt --release --no-translations (Join-Path $stage 'arstack_rc2_acceptance.exe') + + - name: Upload RC2 physical QA kit + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-rc2-acceptance-kit + path: dist/RC2-Acceptance + if-no-files-found: error + retention-days: 14 + + linux-policy: + name: RC2 Linux policy regression + runs-on: ubuntu-latest + container: ubuntu:26.04 + steps: + - name: Install build and Qt dependencies + shell: bash + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates git cmake ninja-build g++ \ + qt6-base-dev qt6-declarative-dev qt6-serialport-dev \ + qml6-module-qtquick qml6-module-qtquick-controls \ + qml6-module-qtquick-layouts qml6-module-qtquick-dialogs qml6-module-qtquick-window + + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure + shell: bash + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build and self-test RC2 evidence evaluator + shell: bash + run: | + cmake --build build-arstack-studio --target arstack_rc2_acceptance --parallel 2 + ./build-arstack-studio/arstack_rc2_acceptance --self-test From da832c1a6a0aa8032431f1d6b037ee769235bac8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 03:55:14 +0700 Subject: [PATCH 267/272] Release Freeze: pin P0 RC baseline and exception ledger --- .../release/freeze-manifest.json | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/arstack_studio/release/freeze-manifest.json diff --git a/apps/arstack_studio/release/freeze-manifest.json b/apps/arstack_studio/release/freeze-manifest.json new file mode 100644 index 000000000..7bc38dbc2 --- /dev/null +++ b/apps/arstack_studio/release/freeze-manifest.json @@ -0,0 +1,38 @@ +{ + "schema": "arstack.studio.release-freeze.v1", + "product": "ARStack Studio", + "version": "0.1.0", + "freezeBaselineSha": "ed46f3bed73d398d71be2eacf34c4995325c3cad", + "state": "FROZEN_PENDING_PHYSICAL_ACCEPTANCE", + "sourcePr": 79, + "trackingIssue": 83, + "releaseWorkflowRun": 34863664544, + "rc2PolicyRun": 34863664307, + "artifacts": { + "windows": { + "name": "arstack-studio-windows-x64", + "artifactId": 10358737281, + "digest": "sha256:6a74b60b11706e3eab723bfb28827f2138430ce7617d47fa09bcba6617fd90c5" + }, + "firmware": { + "name": "arstack-esp32p4-smv-firmware", + "artifactId": 10358024903, + "digest": "sha256:1cc93abdb60c1e2575ade9ca7eb1a85f7d4ba7cdb52d72280677085fef1676f4" + }, + "rc2AcceptanceKit": { + "name": "arstack-studio-rc2-acceptance-kit", + "artifactId": 10355888453, + "digest": "sha256:2dc83c7ce784dc861eeec6be5b59723ce7c8b9fd23ecc0adbc54fb109518bb3d" + } + }, + "releaseInvariants": { + "layout": "4I+4V", + "fps": 4000, + "asdu": 1, + "payloadBytes": 64, + "smpSynchWithoutMeasuredEvidence": 0, + "autoStartAfterReconnect": false, + "physicalAcceptanceRequiredBeforeMergeOrTag": true + }, + "exceptions": [] +} From c6a91fe6e680e95c7eecf7d0d6e7945fae77180c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 03:55:33 +0700 Subject: [PATCH 268/272] Release Freeze: define P0 blocker-only policy --- docs/SMV_STUDIO_RELEASE_FREEZE.md | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/SMV_STUDIO_RELEASE_FREEZE.md diff --git a/docs/SMV_STUDIO_RELEASE_FREEZE.md b/docs/SMV_STUDIO_RELEASE_FREEZE.md new file mode 100644 index 000000000..07944d4fc --- /dev/null +++ b/docs/SMV_STUDIO_RELEASE_FREEZE.md @@ -0,0 +1,89 @@ +# ARStack Studio P0 Release Freeze + +## Freeze baseline + +The P0 release candidate is frozen from commit: + +`ed46f3bed73d398d71be2eacf34c4995325c3cad` + +This exact source head completed the ARStack Studio Qt, ARStack Studio Release, RC2 acceptance-policy, ESP32-P4 injector, embedded profile, C++, security/evidence, and related branch gates successfully. + +The freeze branch is: + +`release/arstack-studio-p0-freeze` + +PR #79 remains open and unmerged. Issue #83 remains the acceptance authority. + +## Freeze state + +Current state: **FROZEN_PENDING_PHYSICAL_ACCEPTANCE**. + +A green CI/release pipeline is necessary but not sufficient for public release. The remaining release authority is physical acceptance on the real ESP32-P4 hardware, including the RC1 USB/session matrix and RC2 functional/4000-fps evidence. + +Do not merge PR #79, create the final release tag, or publish the public release until issue #83 records physical acceptance. + +## Scope rule + +After the freeze baseline, no new feature, UX expansion, protocol expansion, refactor, layout widening, performance experiment, or unrelated cleanup is allowed. + +The only permitted production changes are **release blockers**. Every blocker change must be declared in `apps/arstack_studio/release/freeze-manifest.json` before it can pass the release-freeze guard. + +Each exception entry must identify: + +- exact repository path; +- blocker issue/reference; +- failure/risk being addressed; +- rationale for changing frozen production code; +- verification required to prove the blocker is fixed without weakening existing gates. + +Governance-only changes to this document, the freeze manifest, and the release-freeze workflow are permitted without an exception. + +## Frozen product boundary + +The public P0 boundary remains: + +- 4 current + 4 voltage channels; +- 4000 samples/s reference profile; +- one ASDU; +- 64-byte sample payload; +- 16 ordered FCDA leaves; +- no automatic Start after reconnect/update; +- explicit, fail-closed firmware write; +- session lease retained as the hard-crash RUNNING failsafe; +- `smpSynch=0` unless separate measured disciplined-clock evidence exists. + +No broader runtime layout or synchronization claim may enter during the freeze. + +## RC evidence pinned at freeze + +- ARStack Studio Release run: `34863664544` — PASS. +- ARStack Studio Qt run: `34863664380` — PASS. +- RC2 Acceptance Policy run: `34863664307` — PASS on Windows and Linux. +- Windows candidate artifact: `arstack-studio-windows-x64`, artifact `10358737281`, digest `sha256:6a74b60b11706e3eab723bfb28827f2138430ce7617d47fa09bcba6617fd90c5`. +- Firmware artifact: `arstack-esp32p4-smv-firmware`, artifact `10358024903`, digest `sha256:1cc93abdb60c1e2575ade9ca7eb1a85f7d4ba7cdb52d72280677085fef1676f4`. +- RC2 physical QA kit: artifact `10355888453`, digest `sha256:2dc83c7ce784dc861eeec6be5b59723ce7c8b9fd23ecc0adbc54fb109518bb3d`. + +## Remaining release gates + +Release remains blocked until the physical evidence in issue #83 proves all of the following: + +1. repeated cold/open cycles produce zero false Install Firmware prompts and converge to bounded READY/actionable error; +2. READY and RUNNING unplug/replug recovery obeys semantic device identity and never auto-Starts; +3. serial/espflash handoff has no COM ownership contention; +4. bundled 4I+4V profile deploys, starts, stops, zeroes, and accepts live edits correctly; +5. retained run is at least 3600 seconds at the 4000-fps target with `missed=0` and canonical `TX fail=0`; +6. independent capture proves the frozen SV identity/layout/counter contract and `smpSynch=0` truth boundary; +7. `arstack_rc2_acceptance` returns exit code 0 for evidence tied to the exact candidate SHA. + +## Blocker-fix protocol + +If a physical RC failure requires code changes: + +1. create or reference a blocker issue; +2. add the exact changed path(s) and evidence requirements to the freeze manifest; +3. make the minimum fix only; +4. rerun the full exact-head Qt, Release, RC2 policy, embedded/injector, security and relevant core gates; +5. repeat the affected physical acceptance case; +6. move the freeze baseline only after the new exact head is fully green and the blocker is documented. + +A freeze-baseline move is a deliberate release-management action, not an incidental commit. From 730c5439ba6d823ec21e998ebf276fa9dc066d7b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 03:55:48 +0700 Subject: [PATCH 269/272] Release Freeze: enforce blocker-only changes above frozen RC --- .../arstack-studio-release-freeze.yml | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .github/workflows/arstack-studio-release-freeze.yml diff --git a/.github/workflows/arstack-studio-release-freeze.yml b/.github/workflows/arstack-studio-release-freeze.yml new file mode 100644 index 000000000..60a87f908 --- /dev/null +++ b/.github/workflows/arstack-studio-release-freeze.yml @@ -0,0 +1,112 @@ +name: ARStack Studio Release Freeze + +on: + push: + branches: + - release/arstack-studio-p0-freeze + pull_request: + branches: + - release/arstack-studio-p0-freeze + workflow_dispatch: + +permissions: + contents: read + +jobs: + freeze-guard: + name: Blocker-only freeze guard + runs-on: ubuntu-latest + steps: + - name: Checkout full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Enforce freeze baseline and declared blocker exceptions + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import json + import pathlib + import subprocess + import sys + + manifest_path = pathlib.Path('apps/arstack_studio/release/freeze-manifest.json') + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + baseline = manifest['freezeBaselineSha'] + + subprocess.run(['git', 'cat-file', '-e', f'{baseline}^{{commit}}'], check=True) + changed = subprocess.check_output( + ['git', 'diff', '--name-only', f'{baseline}...HEAD'], + text=True, + ).splitlines() + + governance = { + 'apps/arstack_studio/release/freeze-manifest.json', + 'docs/SMV_STUDIO_RELEASE_FREEZE.md', + '.github/workflows/arstack-studio-release-freeze.yml', + } + + exceptions = manifest.get('exceptions', []) + declared = {} + required = {'path', 'blockerIssue', 'risk', 'rationale', 'verification'} + errors = [] + + for item in exceptions: + missing = sorted(required - set(item)) + if missing: + errors.append(f"freeze exception missing {missing}: {item}") + continue + path = item['path'] + if not isinstance(path, str) or not path.strip(): + errors.append(f'invalid exception path: {item}') + continue + if path in declared: + errors.append(f'duplicate freeze exception path: {path}') + continue + for field in ('blockerIssue', 'risk', 'rationale', 'verification'): + if not isinstance(item[field], str) or not item[field].strip(): + errors.append(f'empty {field} for freeze exception: {path}') + declared[path] = item + + production_changes = [p for p in changed if p not in governance] + undeclared = [p for p in production_changes if p not in declared] + stale = [p for p in declared if p not in production_changes] + + if manifest.get('state') != 'FROZEN_PENDING_PHYSICAL_ACCEPTANCE': + errors.append('freeze manifest state must remain FROZEN_PENDING_PHYSICAL_ACCEPTANCE until physical acceptance is recorded') + + invariants = manifest.get('releaseInvariants', {}) + expected = { + 'layout': '4I+4V', + 'fps': 4000, + 'asdu': 1, + 'payloadBytes': 64, + 'smpSynchWithoutMeasuredEvidence': 0, + 'autoStartAfterReconnect': False, + 'physicalAcceptanceRequiredBeforeMergeOrTag': True, + } + for key, value in expected.items(): + if invariants.get(key) != value: + errors.append(f'release invariant changed: {key}={invariants.get(key)!r}, expected {value!r}') + + if undeclared: + errors.append('undeclared production changes above freeze baseline: ' + ', '.join(undeclared)) + if stale: + errors.append('freeze exceptions do not correspond to changed files: ' + ', '.join(stale)) + + print(f'Freeze baseline: {baseline}') + print('Changed paths above baseline:') + for path in changed: + marker = 'governance' if path in governance else 'declared-blocker' if path in declared else 'UNDECLARED' + print(f' [{marker}] {path}') + + if errors: + print('\nRELEASE FREEZE: FAIL', file=sys.stderr) + for error in errors: + print(f' - {error}', file=sys.stderr) + sys.exit(1) + + print('\nRELEASE FREEZE: PASS · no feature/scope change above frozen RC; production deltas are blocker-declared only') + PY From 7028c2dafdee5f9a4fc9469062d78a0b04c8f77e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 04:22:56 +0700 Subject: [PATCH 270/272] Release Freeze: pin PR merge artifact provenance --- apps/arstack_studio/release/freeze-manifest.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/release/freeze-manifest.json b/apps/arstack_studio/release/freeze-manifest.json index 7bc38dbc2..74e1fcaae 100644 --- a/apps/arstack_studio/release/freeze-manifest.json +++ b/apps/arstack_studio/release/freeze-manifest.json @@ -3,6 +3,10 @@ "product": "ARStack Studio", "version": "0.1.0", "freezeBaselineSha": "ed46f3bed73d398d71be2eacf34c4995325c3cad", + "artifactBuildCommit": "47aec97ac2887e495c49e3f63459b695e26d8feb", + "artifactBuildTree": "3bf3b7ac1a1aaf9b148c8e51b8f236b49dd2c51e", + "freezeBaselineTree": "d071404a4ff4ffa8bd3982023bafaa6159d8ce5f", + "artifactBuildDeltaFromFreeze": "documentation-only (README / MMS discovery docs); no ARStack Studio, embedded firmware, supervisor, or realtime publisher source delta", "state": "FROZEN_PENDING_PHYSICAL_ACCEPTANCE", "sourcePr": 79, "trackingIssue": 83, From acd81524d59bd670f1ea2805ac755f5c1f34d6d5 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 15 Sep 2026 05:16:04 +0700 Subject: [PATCH 271/272] fix(esp32p4): eliminate START timing telemetry blocker --- .github/workflows/esp32p4-smv-injector.yml | 22 ++++++ .../release/freeze-manifest.json | 17 ++++- .../esp32p4_smv_injector/main/app_main.cpp | 69 +++++++++++++++++-- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index d73731207..b43450b0c 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -55,6 +55,28 @@ jobs: grep -qx 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' sdkconfig echo 'ESP32-P4 pre-v3 build policy: PASS' + - name: Verify realtime START and telemetry boundary policy + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + source = Path('main/app_main.cpp').read_text(encoding='utf-8') + start_begin = source.index('if (take_start_request())') + start_end = source.index('if (!live_tx_running())', start_begin) + start_block = source[start_begin:start_end] + clock_start = start_block.rindex( + 'sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz);' + ) + assert start_block.rindex('ESP_LOGI') < clock_start + assert 'ESP_LOG' not in start_block[clock_start:] + assert 'static_assert(std::atomic::is_always_lock_free);' in source + assert 'g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed);' in source + assert 'elapsed_ticks) * 1000000ULL' in source + assert 'rate_baseline_valid = true;' in source + print('Realtime boundary policy: PASS - timer starts after formatting and telemetry rate is elapsed-time normalized') + PY + - name: Build firmware run: source /opt/esp/idf/export.sh && idf.py build diff --git a/apps/arstack_studio/release/freeze-manifest.json b/apps/arstack_studio/release/freeze-manifest.json index 74e1fcaae..cd5855d7c 100644 --- a/apps/arstack_studio/release/freeze-manifest.json +++ b/apps/arstack_studio/release/freeze-manifest.json @@ -38,5 +38,20 @@ "autoStartAfterReconnect": false, "physicalAcceptanceRequiredBeforeMergeOrTag": true }, - "exceptions": [] + "exceptions": [ + { + "path": "embedded/esp32p4_smv_injector/main/app_main.cpp", + "blockerIssue": "#83 physical RC2 retained-run telemetry failure", + "risk": "The 250 us sample clock starts before synchronous profile logging completes, causing deterministic missed slots at every START; asynchronous telemetry buckets are also mislabeled as one-second fps windows.", + "rationale": "Physical board evidence showed the first stream counter starting after skipped slots and fail-closed RC2 telemetry outside 3999..4001 despite a stable 3999.94 fps wire segment. The minimum production fix moves timer enable after preparation and reports elapsed-time-normalized fps from a bounded lock-free tick counter.", + "verification": "ESP32-P4 build and realtime boundary policy gate; real-board 10x Start/Stop; first transmitted smpCnt begins at 0 with missed=0; retained 4000-fps telemetry has missed=0 and TX fail=0; independent capture proves continuous counters and 3999..4001 fps; full exact-head release gates." + }, + { + "path": ".github/workflows/esp32p4-smv-injector.yml", + "blockerIssue": "#83 physical RC2 retained-run telemetry failure", + "risk": "The timer/log ordering and normalized telemetry invariant could regress without a deterministic source-policy gate.", + "rationale": "Add the smallest CI assertion at the existing ESP32-P4 firmware boundary instead of introducing a second test framework or weakening the physical evaluator.", + "verification": "The workflow must reject timer enable before START-path formatting, missing lock-free sample tick accounting, or removal of elapsed-time telemetry normalization; the complete ESP32-P4 firmware build must still pass." + } + ] } diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 9fae74595..ee41f223d 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -77,6 +78,8 @@ struct TimingStats final { }; TimingStats g_interval_stats{}; +static_assert(std::atomic::is_always_lock_free); +std::atomic g_sample_tick_total{0U}; struct PacketTemplate final { std::vector bytes; @@ -296,6 +299,21 @@ void merge_stats(TimingStats& local) noexcept { local = TimingStats{}; } +void accumulate_stats(TimingStats& target, const TimingStats& source) noexcept { + if (source.samples == 0U) return; + target.samples += source.samples; + target.canonical_ok += source.canonical_ok; + target.canonical_fail += source.canonical_fail; + target.mirror_ok += source.mirror_ok; + target.mirror_fail += source.mirror_fail; + target.missed_slots += source.missed_slots; + target.lateness_sum_us += source.lateness_sum_us; + target.lateness_min_us = std::min(target.lateness_min_us, source.lateness_min_us); + target.lateness_max_us = std::max(target.lateness_max_us, source.lateness_max_us); + target.last_sample_count = source.last_sample_count; + target.last_signal_generation = source.last_signal_generation; +} + void reset_interval_stats() noexcept { portENTER_CRITICAL(&g_stats_mux); g_interval_stats = TimingStats{}; @@ -450,8 +468,6 @@ void publisher_task(void* argument) { schedule_anchored = false; expected_schedule.reset(active_profile.publisher_rate_hz); static_cast(expected_schedule.next_ticks()); // first alarm interval - sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz); - ESP_LOGI(kTag, "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", static_cast(active_profile.generation), @@ -464,6 +480,12 @@ void publisher_task(void* argument) { static_cast(active_profile.vlan_id), static_cast(active_profile.vlan_priority), static_cast(canonical.bytes.size())); + + // Starting the 250 us clock is the final operation in this branch. + // Formatting the profile log after timer enable previously queued + // dozens of ISR notifications before the publisher could consume + // its first tick, so every START falsely began with missed slots. + sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz); continue; } @@ -537,6 +559,7 @@ void publisher_task(void* argument) { update_local_timing( local, lateness_us, missed_slots, canonical_sample_count, signal_state.generation); + g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); canonical_sample_count = static_cast( (canonical_sample_count + 1U) % active_profile.sample_counter_modulus); mirror_sample_count = static_cast(mirror_sample_count + 1U); @@ -550,6 +573,11 @@ void publisher_task(void* argument) { } void telemetry_task(void*) { + std::uint32_t previous_tick_total = 0U; + std::int64_t previous_tick_time_us = 0; + bool rate_baseline_valid = false; + TimingStats baseline_stats{}; + while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); TimingStats snapshot{}; @@ -557,14 +585,47 @@ void telemetry_task(void*) { snapshot = g_interval_stats; g_interval_stats = TimingStats{}; portEXIT_CRITICAL(&g_stats_mux); - if (snapshot.samples == 0U) continue; + + const std::int64_t now_us = esp_timer_get_time(); + const std::uint32_t tick_total = g_sample_tick_total.load(std::memory_order_relaxed); + if (!live_tx_running() || snapshot.samples == 0U) { + rate_baseline_valid = false; + baseline_stats = {}; + previous_tick_total = tick_total; + previous_tick_time_us = now_us; + continue; + } + + // The telemetry task is independent from START and can first wake in + // the middle of a publisher second. Use that partial bucket only as a + // baseline, while carrying its failures into the first complete report. + if (!rate_baseline_valid) { + rate_baseline_valid = true; + baseline_stats = snapshot; + previous_tick_total = tick_total; + previous_tick_time_us = now_us; + continue; + } + + const std::uint32_t elapsed_ticks = tick_total - previous_tick_total; + const std::int64_t elapsed_us = now_us - previous_tick_time_us; + previous_tick_total = tick_total; + previous_tick_time_us = now_us; + if (elapsed_ticks == 0U || elapsed_us <= 0) continue; + + accumulate_stats(snapshot, baseline_stats); + baseline_stats = {}; + const std::uint64_t normalized_fps = + (static_cast(elapsed_ticks) * 1000000ULL + + static_cast(elapsed_us / 2)) / + static_cast(elapsed_us); const std::int64_t mean_lateness_us = snapshot.lateness_sum_us / static_cast(snapshot.samples); ESP_LOGI(kTag, "timing: samples=%llu (~%llu fps) MC ok=%llu fail=%llu mirror ok=%llu fail=%llu missed=%llu wake_late_us[min/mean/max]=%ld/%lld/%ld smpCnt=%u signal_gen=%llu", static_cast(snapshot.samples), - static_cast(snapshot.samples), + static_cast(normalized_fps), static_cast(snapshot.canonical_ok), static_cast(snapshot.canonical_fail), static_cast(snapshot.mirror_ok), From d9b5b6848415c7e6d1c52ec929e57c66b608058d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 08:41:48 +0700 Subject: [PATCH 272/272] Merge prep: sync AGENTS.md with current main contract --- AGENTS.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8335ceebd..8b6e1ecb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,6 +276,42 @@ Report: - exact validation executed; - remaining genuine limitations. +## 20. Mandatory canonical IEC 61850 model / SCL contract + +Before changing **live discovery, Open SCL, SCL-assisted connect, IED-name resolution, DataSet/RCB model reconstruction, SCL type synthesis, Save SCL, or Edition conversion**, read [`AI_READ_FIRST.md`](AI_READ_FIRST.md), [`SCL_EXPORT.md`](SCL_EXPORT.md), [`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). + +The non-negotiable architecture is: + +```text +LIVE MMS DISCOVERY --------+ + | + v + CANONICAL IED MODEL + ^ + | +OPEN SCL -> typed normalize-+ + | + v + SHARED EDITION EXPORTERS +``` + +Rules: +- There is **one semantic source of truth**. Do not create separate discovered-IED and opened-SCL semantic trees, caches, resolvers, or exporters. +- Live discovery and Open SCL are different **ingress adapters** into the same canonical IEC 61850 semantics. +- Open SCL is a typed semantic import. The XML DOM/tree is source evidence, not the application/protocol source of truth. +- Save SCL is a local projection of the canonical model. Selecting another target edition must not trigger hidden rediscovery or require a live connection. +- Source edition and target edition are handled by typed import/export profiles. Do not implement conversion with global string replacement or schema-specific branches scattered through discovery/runtime code. +- Keep file-declared structure/configuration separate from current live runtime overlays such as values, RCB ownership, EntryID, runtime-added DataSets, and association state. +- `IED@name` is authoritative for an opened file. A discovery-derived IED name must be evidence-scored. SCL-assisted connect cross-checks the two and reports mismatch; it must not silently rename either model. +- Preserve DataSet member order and semantic references exactly. +- Treat `Unknown`, `NotRepresentableInSourceProfile`, and `KnownFalse` as different states. Never silently convert missing/unrepresentable semantics to false or a guessed default. +- Source-only metadata such as original type IDs, Header/history, descriptions, topology, and private extensions belongs to provenance/source evidence; it must not become a second semantic model. +- Discovery-derived type IDs are deterministic synthetic identifiers unless independently known; never present them as recovered original vendor IDs. +- A worker/thread may provide responsiveness, progress, parsing, serialization, or validation off the UI thread. It must not be the reason identity or model semantics are correct. +- Same-edition normalized round-trip acceptance is **semantic idempotence**, not byte-for-byte XML equality. Cross-edition round-trip compares only the representable semantic subset and must report loss explicitly. + +If a patch cannot explain how it preserves this single-model contract, stop and redesign before coding. + ## Final rule Think like the engineer responsible for a protocol stack and embedded device under sustained field load for years, not like a prototype generator trying to make one packet or screenshot pass.