Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions apps/arstack_studio/qml/ConfigurationHub.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -132,6 +136,7 @@ Item {
ExpertTab { text: "Firmware" }
ExpertTab { text: "Waveform" }
ExpertTab { text: "Time Sync" }
ExpertTab { text: "Capture" }
ExpertTab { text: "Device" }
}

Expand Down Expand Up @@ -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
}
}
}
}
9 changes: 0 additions & 9 deletions apps/arstack_studio/qml/DeviceExpertPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
Expand Down
7 changes: 7 additions & 0 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions apps/arstack_studio/qml/ModernRibbon.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 61 additions & 4 deletions apps/arstack_studio/qml/SvCapturePanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Rectangle {
radius: 8
border.width: 1
border.color: theme.lineSoft
implicitHeight: 258
implicitHeight: 390

SvCaptureController { id: capture }

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
43 changes: 37 additions & 6 deletions apps/arstack_studio/src/SvCaptureController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -222,6 +229,7 @@ SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) {
connect(&decoder_, &QFutureWatcher<QVariantMap>::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();
});
Expand All @@ -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..."));
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -724,6 +742,19 @@ QVariantMap SvCaptureController::decodePcap(
return result;
}
json.close();
if (usable) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return decoded counters when no valid SV frame is found

When an operator selects the wrong TAP/SPAN adapter or captures only PTP traffic, parsing can succeed and ptpFrames can contain useful evidence while matched == 0; however, usable is false and this condition suppresses the entire summary. The QML panel consequently displays none of the decoded SV/PTP counters and only reports “No valid SV packets,” hiding the PTP count that would help diagnose the capture setup. Populate the summary for completed parses while retaining the INVALID_CAPTURE status.

Useful? React with 👍 / 👎.

// 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.")
Expand Down
6 changes: 6 additions & 0 deletions apps/arstack_studio/src/SvCaptureController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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_;
Expand Down
9 changes: 8 additions & 1 deletion apps/arstack_studio/src/SvCaptureHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
28 changes: 28 additions & 0 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
Loading