feat: add recording toggle, stream stats UI
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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))
|
||||
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user