feat: add recording toggle, stream stats UI
This commit is contained in:
@@ -205,33 +205,37 @@ Item {
|
|||||||
font.letterSpacing: 1.2
|
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
|
// Stream statistics popup (Live mode)
|
||||||
// RowLayout {
|
Button {
|
||||||
// spacing: 5
|
visible: streamController.mode === "live"
|
||||||
// visible: streamController.mode === "live" && streamController.state !== "idle"
|
text: "Stats"
|
||||||
// Rectangle {
|
onClicked: streamStatsDialog.open()
|
||||||
// width: 7; height: 7; radius: 4
|
}
|
||||||
// color: Theme.liveColor
|
|
||||||
// }
|
StreamStatsDialog {
|
||||||
// Label {
|
id: streamStatsDialog
|
||||||
// text: "LIVE " + root.fmtTime(root._liveSecs)
|
elapsedSecs: root._liveSecs
|
||||||
// color: Theme.liveColor
|
}
|
||||||
// font.pixelSize: Theme.fontXs
|
|
||||||
// font.weight: Font.Medium
|
|
||||||
// font.letterSpacing: 0.8
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 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 {
|
Button {
|
||||||
text: "Export PNG"
|
text: "Export PNG"
|
||||||
onClicked: exportDialog.open()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ WaferMapView 1.0 WaferMapView.qml
|
|||||||
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
||||||
RailActionButton 1.0 RailActionButton.qml
|
RailActionButton 1.0 RailActionButton.qml
|
||||||
SplitDialog 1.0 SplitDialog.qml
|
SplitDialog 1.0 SplitDialog.qml
|
||||||
|
StreamStatsDialog 1.0 StreamStatsDialog.qml
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,10 @@ class SessionController(QObject):
|
|||||||
def errorCount(self) -> int:
|
def errorCount(self) -> int:
|
||||||
return self._error_count
|
return self._error_count
|
||||||
|
|
||||||
|
@Property(int, notify=liveStatsChanged)
|
||||||
|
def resyncCount(self) -> int:
|
||||||
|
return self._reader.resync_count if self._reader else 0
|
||||||
|
|
||||||
# ---- mode + thresholds ----
|
# ---- mode + thresholds ----
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -700,9 +704,6 @@ class SessionController(QObject):
|
|||||||
self._trend_timestamps.pop(0)
|
self._trend_timestamps.pop(0)
|
||||||
self._trend_buffer.pop(0)
|
self._trend_buffer.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _flush_repaint(self) -> None:
|
def _flush_repaint(self) -> None:
|
||||||
if not self._dirty:
|
if not self._dirty:
|
||||||
return
|
return
|
||||||
@@ -759,55 +760,4 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
def _flush_trend(self) -> None:
|
def _flush_trend(self) -> None:
|
||||||
import json
|
import json
|
||||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
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))
|
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ class StreamReader:
|
|||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self.error_count = 0
|
self.error_count = 0
|
||||||
|
# Cumulative count of resync events (lost sync marker) for stream stats.
|
||||||
|
self.resync_count = 0
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
@@ -158,6 +160,7 @@ class StreamReader:
|
|||||||
else:
|
else:
|
||||||
# Resync: keep only trailing 0xAA if present, then read more.
|
# Resync: keep only trailing 0xAA if present, then read more.
|
||||||
resync_attempts += 1
|
resync_attempts += 1
|
||||||
|
self.resync_count += 1
|
||||||
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
|
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
|
||||||
log.error(
|
log.error(
|
||||||
"StreamReader: giving up after %d resync attempts "
|
"StreamReader: giving up after %d resync attempts "
|
||||||
|
|||||||
@@ -64,6 +64,26 @@ def test_compare_files_integration(controller):
|
|||||||
assert "series_a" in args
|
assert "series_a" in args
|
||||||
assert "series_b" 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):
|
def test_split_data_integration(controller):
|
||||||
# Mock the splitResult signal
|
# Mock the splitResult signal
|
||||||
mock_slot = MagicMock()
|
mock_slot = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user