feat: add recording toggle, stream stats UI

This commit is contained in:
jack
2026-07-06 12:44:43 -07:00
parent 3cc10ea7fe
commit 6996df73f8
7 changed files with 186 additions and 81 deletions
+29 -25
View File
@@ -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()
@@ -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)
}
}
}
}
}
+1
View File
@@ -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
-1
View File
@@ -1,5 +1,4 @@
import logging
import os
import sys
from pathlib import Path
@@ -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))
+3
View File
@@ -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 "
+20
View File
@@ -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()