From 0b28f22d7f2ee750aecb02227cf816758f909f2f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:56:30 +0700 Subject: [PATCH 01/10] feat(studio): expose first-class capture command without scripts --- apps/arstack_studio/qml/Main.qml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index d36223f43..e51575e24 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -334,6 +334,13 @@ ApplicationWindow { workflowBar.openEngineeringDialog() } + function openCapture() { + // A single first-class Studio capture workspace. Reuse the same + // lifetime-owned panel even while changing advanced tabs. + profilePanel.showCapture() + openConfiguration() + } + function openConfiguration() { configurationWindow.show() configurationWindow.raise() From d20ac121cd41a1bbb6e3daf7e23d370ce4e87126 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:56:33 +0700 Subject: [PATCH 02/10] feat(studio): one persistent dedicated Capture tab, no duplicate worker --- apps/arstack_studio/qml/ConfigurationHub.qml | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 22a737c40..9c52a6420 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -19,6 +19,10 @@ Item { function openEngineeringFile() { profileInspector.openEngineeringFile() } function showRecovery() { expertTabs.currentIndex = 1 } function showTiming() { expertTabs.currentIndex = 3 } + function showCapture() { + captureTabVisited = true + expertTabs.currentIndex = 4 + } // The Main tray always shares the Advanced PTP profile and verified // guided START state machine; never create an independent START sequence. function startPtpRole(role) { return timingPanel.startRole(role) } @@ -132,6 +136,7 @@ Item { ExpertTab { text: "Firmware" } ExpertTab { text: "Waveform" } ExpertTab { text: "Time Sync" } + ExpertTab { text: "Capture" } ExpertTab { text: "Device" } } @@ -175,19 +180,26 @@ Item { monoFont: hub.monoFont } Loader { - // Never invoke Npcap interface discovery on the main Studio startup. - // Once opened, keep this item alive across tab switches so a running - // 120s/3600s capture cannot be aborted by merely browsing Advanced. + // Capture adapter discovery starts ONLY after the operator opens + // this workspace. Keep the worker alive during tab navigation, + // including sustained 3600 s captures, without another owner. active: hub.captureTabVisited - sourceComponent: DeviceExpertPanel { + sourceComponent: SvCapturePanel { theme: hub.theme device: hub.device - session: hub.session signalFrequency: hub.signalFrequency uiFont: hub.uiFont monoFont: hub.monoFont } } + DeviceExpertPanel { + theme: hub.theme + device: hub.device + session: hub.session + signalFrequency: hub.signalFrequency + uiFont: hub.uiFont + monoFont: hub.monoFont + } } } } From ea2f980c4a41518b1f0eba6672db0e957840cf4d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:56:35 +0700 Subject: [PATCH 03/10] feat(studio): primary Capture action beside live signal controls --- apps/arstack_studio/qml/ModernRibbon.qml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index d10d5c5d1..b8460bcb1 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -194,6 +194,16 @@ Rectangle { } } + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Capture" + iconSource: Qt.resolvedUrl("../assets/material/monitor_heart.svg") + tone: "accent" + toolTipText: "Record independent Ethernet SV + PTP and review evidence in Studio" + onClicked: bar.controller.openCapture() + } + RibbonAction { theme: bar.theme uiFont: bar.uiFont From 490eeaf05b4bc8e23c842689a3abc5016566117b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:56:38 +0700 Subject: [PATCH 04/10] refactor(studio): remove hidden duplicate capture from Device setup --- apps/arstack_studio/qml/DeviceExpertPanel.qml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index fc630ad3f..352e4aed4 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -101,15 +101,6 @@ SurfacePanel { font.pixelSize: 9 wrapMode: Text.WordWrap } - SvCapturePanel { - Layout.fillWidth: true - Layout.preferredHeight: 268 - theme: panel.theme - device: panel.device - signalFrequency: panel.signalFrequency - uiFont: panel.uiFont - monoFont: panel.monoFont - } Item { Layout.fillHeight: true; implicitHeight: 16 } } } From a7218672e77159e155ba4fd7043fd34611379e53 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:58:29 +0700 Subject: [PATCH 05/10] feat(studio-capture): expose compact decoded evidence summary and support state --- apps/arstack_studio/src/SvCaptureController.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp index 174426878..973ce2cd2 100644 --- a/apps/arstack_studio/src/SvCaptureController.hpp +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -17,6 +17,8 @@ class SvCaptureController : public QObject { Q_PROPERTY(bool processing READ processing NOTIFY stateChanged) Q_PROPERTY(QString outputFolder READ outputFolder NOTIFY statusChanged) Q_PROPERTY(QString lastReport READ lastReport NOTIFY statusChanged) + Q_PROPERTY(QVariantMap summary READ summary NOTIFY statusChanged) + Q_PROPERTY(bool captureSupportAvailable READ captureSupportAvailable NOTIFY statusChanged) public: explicit SvCaptureController(QObject* parent = nullptr); ~SvCaptureController() override; @@ -28,12 +30,15 @@ class SvCaptureController : public QObject { [[nodiscard]] bool processing() const { return decoder_.isRunning(); } [[nodiscard]] QString outputFolder() const { return outputFolder_; } [[nodiscard]] QString lastReport() const { return lastReport_; } + [[nodiscard]] QVariantMap summary() const { return summary_; } + [[nodiscard]] bool captureSupportAvailable() const { return !dumpcap_.isEmpty(); } Q_INVOKABLE void refreshInterfaces(); Q_INVOKABLE bool startCapture(int index, int seconds, const QVariantMap& studioTelemetry); Q_INVOKABLE void abortCapture(); Q_INVOKABLE void sampleTelemetry(const QVariantMap& passiveSnapshot); Q_INVOKABLE void openOutputFolder(); + Q_INVOKABLE void openCaptureSupportHelp(); signals: void interfacesChanged(); @@ -59,6 +64,7 @@ class SvCaptureController : public QObject { QString status_{QStringLiteral("Discover an Ethernet capture adapter to begin.")}; QString outputFolder_; QString lastReport_; + QVariantMap summary_; QString activePcap_; QString activeCsv_; QString activeSeconds_; From 7883bdde843479b9a6f4a58ef96ccedca18030d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:58:32 +0700 Subject: [PATCH 06/10] feat(studio-capture): report wire facts in GUI and make capture setup clickable --- .../src/SvCaptureController.cpp | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index aa8191271..5ec9f7a3a 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -155,19 +155,26 @@ bool parseSvFrame(const QByteArrayView frame, Sample& sample) { QString formatError(const QString& prefix, const QString& detail) { return prefix + QStringLiteral(": ") + detail; } -} // namespace -SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) { - dumpcap_ = QStandardPaths::findExecutable(QStringLiteral("dumpcap")); +// Capture remains a single Studio action. The external driver/recorder is +// discovered automatically; the operator never enters a shell command. +QString findDumpcap() { + QString found = QStandardPaths::findExecutable(QStringLiteral("dumpcap")); #ifdef Q_OS_WIN - if (dumpcap_.isEmpty()) { + if (found.isEmpty()) { for (const QString& path : { QStringLiteral("C:/Program Files/Wireshark/dumpcap.exe"), QStringLiteral("C:/Program Files (x86)/Wireshark/dumpcap.exe")}) { - if (QFileInfo::exists(path)) { dumpcap_ = path; break; } + if (QFileInfo::exists(path)) { found = path; break; } } } #endif + return found; +} +} // namespace + +SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) { + dumpcap_ = findDumpcap(); auto documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); if (documents.isEmpty()) documents = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); outputFolder_ = QDir(documents).filePath(QStringLiteral("ARStack Studio/Captures")); @@ -222,6 +229,7 @@ SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) { connect(&decoder_, &QFutureWatcher::finished, this, [this] { const QVariantMap report = decoder_.result(); lastReport_ = report.value(QStringLiteral("json")).toString(); + summary_ = report.value(QStringLiteral("summary")).toMap(); setStatus(report.value(QStringLiteral("status")).toString()); emit stateChanged(); }); @@ -245,8 +253,11 @@ void SvCaptureController::setStatus(const QString& text) { } void SvCaptureController::refreshInterfaces() { if (discovery_.state() != QProcess::NotRunning || capturing() || processing()) return; + // Refresh installation state too; no Studio restart is required after + // first-time installing Npcap/Wireshark from the in-app help button. + dumpcap_ = findDumpcap(); if (dumpcap_.isEmpty()) { - setStatus(QStringLiteral("Capture requires Wireshark dumpcap + Npcap. Install Wireshark including Npcap, then Refresh.")); + setStatus(QStringLiteral("Capture driver not ready. Select 'Install capture support', complete Wireshark + Npcap setup, then Refresh.")); return; } setStatus(QStringLiteral("Finding capture interfaces...")); @@ -286,6 +297,7 @@ bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap activeSecondsRequested_ = seconds; abortRequested_ = false; lastReport_.clear(); + summary_.clear(); const QStringList args{ QStringLiteral("-i"), activeAdapter_, QStringLiteral("-f"), QString::fromLatin1(kSvBpf), @@ -345,6 +357,12 @@ void SvCaptureController::openOutputFolder() { if (!QDir().mkpath(outputFolder_)) return; QDesktopServices::openUrl(QUrl::fromLocalFile(outputFolder_)); } +void SvCaptureController::openCaptureSupportHelp() { + // Operator-only setup action. Do not silently install kernel drivers or + // download executable binaries without explicit system authorization. + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://www.wireshark.org/download.html"))); +} QVariantMap SvCaptureController::decodePcap( const QString& pcapFile, const QString& csvFile, const QString& secondsFile, @@ -724,6 +742,19 @@ QVariantMap SvCaptureController::decodePcap( return result; } json.close(); + if (usable) { + // The Studio operator view needs compact truthful wire facts, not + // the full 3600-entry telemetry series kept in the evidence JSON. + result.insert(QStringLiteral("summary"), QVariantMap{ + {QStringLiteral("sv_frames"), qulonglong(matched)}, + {QStringLiteral("ptp_frames"), qulonglong(ptpFrames)}, + {QStringLiteral("missing_samples"), qulonglong(missing)}, + {QStringLiteral("sync_transitions"), qulonglong(syncTransitions)}, + {QStringLiteral("observed_fps"), observedFps}, + {QStringLiteral("status"), report.value(QStringLiteral("status"))}, + {QStringLiteral("physical_acceptance"), false} + }); + } result.insert(QStringLiteral("status"), usable ? QStringLiteral("Capture saved: %1 SV / %2 PTP frames, %3 missing, %4 sync transitions. " "Status: %5. Share report.json + events.csv first; keep PCAP for deep analysis.") From 1c9b0f01244f268bc37db50cd125738c05193ebc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:58:34 +0700 Subject: [PATCH 07/10] feat(studio-capture): show SV/PTP counters and first-run capture support in Studio --- apps/arstack_studio/qml/SvCapturePanel.qml | 65 ++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml index 5d4295408..1d82c503f 100644 --- a/apps/arstack_studio/qml/SvCapturePanel.qml +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -15,7 +15,7 @@ Rectangle { radius: 8 border.width: 1 border.color: theme.lineSoft - implicitHeight: 258 + implicitHeight: 390 SvCaptureController { id: capture } @@ -62,7 +62,7 @@ Rectangle { RowLayout { Layout.fillWidth: true Label { - text: "SV + PTP WIRE CAPTURE" + text: "CAPTURE · SV + PTP" color: panel.theme.text font.family: panel.uiFont font.pixelSize: 12 @@ -79,7 +79,7 @@ Rectangle { } Label { Layout.fillWidth: true - text: "Capture independent IEC 61850 SV and PTP Ethernet traffic in one timeline. No injector commands are sent. Requires Wireshark dumpcap + Npcap and a TAP/SPAN receiving the traffic." + text: "Record independent SV + PTP Ethernet while injection continues. Choose an Ethernet adapter and duration, then press Capture. One-time Windows capture support (Wireshark/Npcap) and a traffic-visible TAP/SPAN may be required." wrapMode: Text.WordWrap color: panel.theme.textSoft font.family: panel.uiFont @@ -118,6 +118,14 @@ Rectangle { enabled: !capture.capturing && !capture.processing onClicked: capture.refreshInterfaces() } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Install capture support" + visible: !capture.captureSupportAvailable + enabled: !capture.capturing && !capture.processing + onClicked: capture.openCaptureSupportHelp() + } } RowLayout { Layout.fillWidth: true @@ -168,6 +176,55 @@ Rectangle { onClicked: capture.openOutputFolder() } } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 92 + visible: capture.summary.sv_frames !== undefined + radius: 8 + color: panel.theme.surface + border.width: 1 + border.color: panel.theme.lineSoft + GridLayout { + anchors.fill: parent + anchors.margins: 10 + columns: 4 + rowSpacing: 5 + columnSpacing: 10 + Repeater { + model: [ + { label: "SV packets", value: capture.summary.sv_frames }, + { label: "PTP packets", value: capture.summary.ptp_frames }, + { label: "Missing samples", value: capture.summary.missing_samples }, + { label: "smpSynch changes", value: capture.summary.sync_transitions } + ] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + spacing: 2 + Label { + text: modelData.value === undefined ? "—" : String(modelData.value) + color: panel.theme.text + font.family: panel.monoFont + font.pixelSize: 16 + font.weight: Font.DemiBold + } + Label { + text: modelData.label + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + } + } + Label { + Layout.columnSpan: 4 + text: "Wire capture saved · Physical synchronization is not yet verified" + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + } + } + } Label { Layout.fillWidth: true text: capture.status @@ -180,7 +237,7 @@ Rectangle { } Label { Layout.fillWidth: true - text: "Exports: SV+PTP .pcap, 4I+4V samples.csv, seconds.csv, concise events.csv and report.json. For 3600 s the sample CSV is 1:20; raw PCAP preserves every packet. PTP traffic alone never proves clock lock or physical PASS." + text: "Studio saves the raw SV+PTP PCAP, 4I+4V CSV, event transitions and report JSON automatically. Open files to share the evidence. The 3600 s CSV is sampled 1:20; PCAP retains all captured packets. Host capture is not hardware phase proof." wrapMode: Text.WordWrap color: panel.theme.muted font.family: panel.uiFont From ee92ad2ef3def542c270299bd17a2c4649e050ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:59:26 +0700 Subject: [PATCH 08/10] test(studio-capture): verify in-app summary mirrors real production decoder --- apps/arstack_studio/src/SvCaptureHarness.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index 77dca5302..40df159c2 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -169,7 +169,14 @@ class SvCaptureHarness { eventRows.contains("SMPSYNCH_CHANGE") && eventRows.contains("PTP_SYNC") && eventRows.contains("sequence=4660") && - result.value("status").toString().contains("3 SV / 1 PTP"); + result.value("status").toString().contains("3 SV / 1 PTP") && + // The SAME production report powers the beginner-friendly + // in-Studio summary: this is on-wire evidence, not a PASS badge. + result.value("summary").toMap().value("sv_frames").toULongLong() == 3 && + result.value("summary").toMap().value("ptp_frames").toULongLong() == 1 && + result.value("summary").toMap().value("missing_samples").toULongLong() == 1 && + result.value("summary").toMap().value("sync_transitions").toULongLong() == 1 && + !result.value("summary").toMap().value("physical_acceptance").toBool(); if (!ok) qCritical() << "Failed production PCAP fixture:" << result << parsed; return ok; } From a431b513c4d949b979c7f3da1f485931f43dc8d1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:59:29 +0700 Subject: [PATCH 09/10] test(studio-capture): assert visible one-owner workspace and native evidence summary --- .../tests/check_operator_workspace.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 3a065fd5d..cc92fa950 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -397,6 +397,34 @@ def main() -> None: require("--check-operator-ui" in (STUDIO / "src/main.cpp").read_text(encoding="utf-8") and "studio-ui-evidence" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), "Windows geometry/evidence gate must run from existing lifecycle build") + # Capture is a first-class operator workflow, not a hidden Device-only + # widget, a second lifetime-owner, or an external script requirement. + hub = source("ConfigurationHub.qml") + capture_ui = source("SvCapturePanel.qml") + capture_cpp = (STUDIO / "src/SvCaptureController.cpp").read_text(encoding="utf-8") + require('text: "Capture"' in ribbon and + 'onClicked: bar.controller.openCapture()' in ribbon and + "function openCapture()" in main_qml and + "profilePanel.showCapture()" in main_qml and + "function showCapture()" in hub and + 'ExpertTab { text: "Capture" }' in hub and + "active: hub.captureTabVisited" in hub and + "sourceComponent: SvCapturePanel {" in hub and + "SvCapturePanel {" not in source("DeviceExpertPanel.qml"), + "Studio must expose ONE persistent Capture workspace from its main ribbon") + require('text: "Capture SV + PTP"' in capture_ui and + "capture.startCapture(" in capture_ui and + "capture.summary.sv_frames" in capture_ui and + "capture.summary.ptp_frames" in capture_ui and + "capture.summary.missing_samples" in capture_ui and + "capture.summary.sync_transitions" in capture_ui and + "capture.openCaptureSupportHelp()" in capture_ui, + "Studio capture must start in-app and display decoded SV/PTP evidence without terminal scripts") + require("result.insert(QStringLiteral(\"summary\"), QVariantMap{" in capture_cpp and + "result.insert(QStringLiteral(\"summary\"), QVariantMap{" in capture_cpp and + "QDesktopServices::openUrl(" in capture_cpp and + "QString findDumpcap()" in capture_cpp, + "Studio capture results must derive from production wire decoder with click-only setup") print("Studio operator workspace contract: PASS") From c959486a658b1861b064c3cc67e77e3798efde70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 13:01:35 +0700 Subject: [PATCH 10/10] test(studio-capture): match dynamic Capture button label expression --- apps/arstack_studio/tests/check_operator_workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index cc92fa950..4e5e8766f 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -412,7 +412,7 @@ def main() -> None: "sourceComponent: SvCapturePanel {" in hub and "SvCapturePanel {" not in source("DeviceExpertPanel.qml"), "Studio must expose ONE persistent Capture workspace from its main ribbon") - require('text: "Capture SV + PTP"' in capture_ui and + require('"Capture SV + PTP"' in capture_ui and "capture.startCapture(" in capture_ui and "capture.summary.sv_frames" in capture_ui and "capture.summary.ptp_frames" in capture_ui and