From 6996df73f83754cc94fde86b7fa2657b52281d6b Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 6 Jul 2026 12:44:43 -0700 Subject: [PATCH] feat: add recording toggle, stream stats UI --- src/pygui/ISC/Tabs/WaferMapTab.qml | 54 ++++---- .../ISC/Tabs/components/StreamStatsDialog.qml | 128 ++++++++++++++++++ src/pygui/ISC/Tabs/components/qmldir | 1 + src/pygui/__main__.py | 1 - .../backend/controllers/session_controller.py | 60 +------- src/pygui/serialcomm/stream_reader.py | 3 + tests/test_session_controller.py | 20 +++ 7 files changed, 186 insertions(+), 81 deletions(-) create mode 100644 src/pygui/ISC/Tabs/components/StreamStatsDialog.qml diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index b082bc8..122e8bf 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -205,33 +205,37 @@ Item { font.letterSpacing: 1.2 } } + + // Record start/stop toggle (Live mode) + Button { + visible: streamController.mode === "live" + enabled: streamController.state !== "idle" || streamController.recording + text: streamController.recording ? "Stop REC" : "Record" + onClicked: { + if (streamController.recording) { + streamController.stopRecording(); + } else { + var info = deviceController.lastWaferInfo; + var serial = (info && info.length > 1) ? info[1] : ""; + var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss"); + var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv"; + streamController.startRecording(deviceController.saveDataDir + "/" + name, serial); + } + } + } - // LIVE timer - // RowLayout { - // spacing: 5 - // visible: streamController.mode === "live" && streamController.state !== "idle" - // Rectangle { - // width: 7; height: 7; radius: 4 - // color: Theme.liveColor - // } - // Label { - // text: "LIVE " + root.fmtTime(root._liveSecs) - // color: Theme.liveColor - // font.pixelSize: Theme.fontXs - // font.weight: Font.Medium - // font.letterSpacing: 0.8 - // } - // } + // Stream statistics popup (Live mode) + Button { + visible: streamController.mode === "live" + text: "Stats" + onClicked: streamStatsDialog.open() + } + + StreamStatsDialog { + id: streamStatsDialog + elapsedSecs: root._liveSecs + } - // IDLE label (review / not connected) - // Label { - // visible: streamController.mode === "review" || streamController.state === "idle" - // text: streamController.state.toUpperCas`e`() - // color: Theme.bodyColor - // font.pixelSize: Theme.fontXs - // font.weight: Font.Medium - // font.letterSpacing: 1.2 - // } Button { text: "Export PNG" onClicked: exportDialog.open() diff --git a/src/pygui/ISC/Tabs/components/StreamStatsDialog.qml b/src/pygui/ISC/Tabs/components/StreamStatsDialog.qml new file mode 100644 index 0000000..bd1cc6a --- /dev/null +++ b/src/pygui/ISC/Tabs/components/StreamStatsDialog.qml @@ -0,0 +1,128 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import ISC + +// ===== Stream Stats Dialog ===== +// Popup showing live stream statistics: received frames, errors, +// resync count, and elapsed time. Opened from the Live toolbar. + +Popup { + id: root + modal: true + dim: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + width: 320 + implicitHeight: content.implicitHeight + 40 + anchors.centerIn: Overlay.overlay + + // Elapsed seconds supplied by the Live timer in WaferMapTab. + property int elapsedSecs: 0 + + function fmtTime(s) { + var m = Math.floor(s / 60); + var ss = s % 60; + return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss; + } + + background: Rectangle { + radius: Theme.radiusMd + color: Theme.cardBackground + border.color: Theme.cardBorder + border.width: 1 + } + + component StatRow: RowLayout { + property string label + property string value + Layout.fillWidth: true + spacing: 8 + + Label { + text: label + color: Theme.bodyColor + font.pixelSize: Theme.fontXs + Layout.fillWidth: true + } + Label { + text: value + color: Theme.headingColor + font.pixelSize: Theme.fontSm + font.weight: Font.Medium + } + } + + ColumnLayout { + id: content + anchors.fill: parent + anchors.margins: 20 + spacing: 16 + + // ── Header ──────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Label { + text: "STREAM DATA" + font.pixelSize: Theme.fontLg + font.bold: true + color: Theme.headingColor + Layout.fillWidth: true + } + + Button { + text: "✕" + flat: true + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + onClicked: root.close() + + background: Rectangle { + radius: Theme.radiusXs + color: parent.hovered ? Theme.buttonNeutralHover : "transparent" + } + contentItem: Label { + text: parent.text + color: Theme.bodyColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + + // ── Stats box ───────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + implicitHeight: statRows.implicitHeight + 20 + radius: Theme.radiusSm + color: Theme.subtleSectionBackground + border.color: Theme.cardBorder + border.width: 1 + + ColumnLayout { + id: statRows + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + StatRow { + label: "Received frames" + value: streamController.receivedCount + } + StatRow { + label: "Errors" + value: streamController.errorCount + } + StatRow { + label: "Resyncs" + value: streamController.resyncCount + } + StatRow { + label: "Elapsed" + value: root.fmtTime(root.elapsedSecs) + } + } + } + } +} diff --git a/src/pygui/ISC/Tabs/components/qmldir b/src/pygui/ISC/Tabs/components/qmldir index 81d3a0a..b8fac2a 100644 --- a/src/pygui/ISC/Tabs/components/qmldir +++ b/src/pygui/ISC/Tabs/components/qmldir @@ -7,3 +7,4 @@ WaferMapView 1.0 WaferMapView.qml ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml RailActionButton 1.0 RailActionButton.qml SplitDialog 1.0 SplitDialog.qml +StreamStatsDialog 1.0 StreamStatsDialog.qml \ No newline at end of file diff --git a/src/pygui/__main__.py b/src/pygui/__main__.py index f7d7609..03c7f81 100644 --- a/src/pygui/__main__.py +++ b/src/pygui/__main__.py @@ -1,5 +1,4 @@ import logging -import os import sys from pathlib import Path diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index ca95926..b3cca55 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -228,6 +228,10 @@ class SessionController(QObject): def errorCount(self) -> int: return self._error_count + @Property(int, notify=liveStatsChanged) + def resyncCount(self) -> int: + return self._reader.resync_count if self._reader else 0 + # ---- mode + thresholds ---- @Slot(str) @slot_error_boundary @@ -700,9 +704,6 @@ class SessionController(QObject): self._trend_timestamps.pop(0) self._trend_buffer.pop(0) - - - def _flush_repaint(self) -> None: if not self._dirty: return @@ -759,55 +760,4 @@ class SessionController(QObject): def _flush_trend(self) -> None: import json - self.trendData.emit(json.dumps(self._trend_buffer)) - - - self.liveStatsChanged.emit() - - # ---- recording ---- - @Slot(str, str) - @slot_error_boundary - def startRecording(self, path: str, serial: str = "") -> None: - self._recorder.start(path, self._sensors, serial) - self.recordingChanged.emit() - - @Slot() - @slot_error_boundary - def stopRecording(self) -> None: - if self._recorder.is_recording: - self._recorder.stop() - self.recordingChanged.emit() - - @Slot(int, float) - @slot_error_boundary - def replaceSensor(self, index: int, value: float) -> None: - """Override sensor `index` to display `value` every frame.""" - self._sensor_editor.set_replacement(index, value) - self._reprocess_current() - - @Slot(int, float) - @slot_error_boundary - def offsetSensor(self, index: int, delta: float) -> None: - """Shift sensor `index` by `delta` every frame.""" - self._sensor_editor.set_offset(index, delta) - self._reprocess_current() - - @Slot(int) - @slot_error_boundary - def clearSensorEdit(self, index: int) -> None: - """Remove all overrides for sensor `index`.""" - self._sensor_editor.clear(index) - self._reprocess_current() - - @Slot() - @slot_error_boundary - def clearSensorEdits(self) -> None: - """Remove all sensor overrides.""" - self._sensor_editor.clear() - self._reprocess_current() - - def _flush_trend(self) -> None: - import json - self.trendData.emit(json.dumps(self._trend_buffer)) - - + self.trendData.emit(json.dumps(self._trend_buffer)) \ No newline at end of file diff --git a/src/pygui/serialcomm/stream_reader.py b/src/pygui/serialcomm/stream_reader.py index 2c872eb..feb2aa3 100644 --- a/src/pygui/serialcomm/stream_reader.py +++ b/src/pygui/serialcomm/stream_reader.py @@ -30,6 +30,8 @@ class StreamReader: self._thread: threading.Thread | None = None self._stop = threading.Event() self.error_count = 0 + # Cumulative count of resync events (lost sync marker) for stream stats. + self.resync_count = 0 def start(self) -> None: self._stop.clear() @@ -158,6 +160,7 @@ class StreamReader: else: # Resync: keep only trailing 0xAA if present, then read more. resync_attempts += 1 + self.resync_count += 1 if resync_attempts > _MAX_RESYNC_ATTEMPTS: log.error( "StreamReader: giving up after %d resync attempts " diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index 38dba31..ec24039 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -64,6 +64,26 @@ def test_compare_files_integration(controller): assert "series_a" in args assert "series_b" in args +def test_recording_toggle(controller, tmp_path): + csv_path = str(tmp_path / "rec" / "live_test.csv") + controller.startRecording(csv_path, "SN123") + assert controller.recording == True + + controller.stopRecording() + assert controller.recording == False + + # Header written with wafer serial + content = open(csv_path).read() + assert "Wafer ID=SN123" in content + +def test_resync_count_default(controller): + # No active reader -> zero + assert controller.resyncCount == 0 + + controller._reader = MagicMock(resync_count=7) + assert controller.resyncCount == 7 + controller._reader = None + def test_split_data_integration(controller): # Mock the splitResult signal mock_slot = MagicMock()