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 + } } } } 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 } } } 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() 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 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 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.") 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_; 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; } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 3a065fd5d..4e5e8766f 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('"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")