feat: implement slot_error_boundary decorator and add unit tests for session and device controllers

This commit is contained in:
jack
2026-06-18 21:01:37 -07:00
parent 5514653b94
commit 7206334a27
6 changed files with 353 additions and 1 deletions
@@ -12,6 +12,7 @@ from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.utils import slot_error_boundary
from pygui.backend.visualization.graph_view import GraphView
from pygui.serialcomm.data_parser import (
convert_to_temperatures,
@@ -116,6 +117,7 @@ class DeviceController(QObject):
return self._save_data_dir
@Slot(str)
@slot_error_boundary
def setSaveDataDir(self, path: str) -> None:
"""Set the directory for saving parsed CSV data files."""
self._save_data_dir = path
@@ -128,6 +130,7 @@ class DeviceController(QObject):
return self._selected_port
@Slot(str)
@slot_error_boundary
def setSelectedPort(self, port: str) -> None:
"""Set the active serial port from the port selector."""
self._selected_port = port
@@ -170,6 +173,7 @@ class DeviceController(QObject):
return self._graph_view
@Slot(result=object)
@slot_error_boundary
def getChartData(self) -> dict[str, Any]:
"""Extract chart-ready data from the current model.
@@ -208,6 +212,7 @@ class DeviceController(QObject):
self.activityLogUpdated.emit("\n".join(self._activity_log))
@Slot()
@slot_error_boundary
def clearActivityLog(self) -> None:
"""Clear the activity log."""
self._activity_log.clear()
@@ -215,6 +220,7 @@ class DeviceController(QObject):
self._save_status()
@Slot()
@slot_error_boundary
def clearSession(self) -> None:
"""Clear the current session state, allowing a clean detect."""
self._connection_status = "Disconnected"
@@ -242,6 +248,7 @@ class DeviceController(QObject):
# ---- Public Slots ----
@Slot()
@slot_error_boundary
def refreshPorts(self) -> None:
"""Scan for available serial ports and emit updated list."""
ports = self._service.enumerate_ports()
@@ -251,6 +258,7 @@ class DeviceController(QObject):
self._save_status()
@Slot()
@slot_error_boundary
def detectWafer(self) -> None:
"""Scan all serial ports for a wafer (runs in background thread).
@@ -280,6 +288,7 @@ class DeviceController(QObject):
self._detectFinished.emit(port, info)
@Slot(object, object)
@slot_error_boundary
def _handle_detect_finished(
self, port: Optional[str], info: Optional[WaferInfo]
) -> None:
@@ -304,6 +313,7 @@ class DeviceController(QObject):
self._set_operation_progress(False)
@Slot()
@slot_error_boundary
def readMemoryAsync(self) -> None:
"""Read wafer memory in a background thread.
@@ -346,6 +356,7 @@ class DeviceController(QObject):
self._readFinished.emit(port, family_code, data)
@Slot(str, str, object)
@slot_error_boundary
def _handle_read_finished(
self, port: str, family_code: str, data: Optional[bytes]
) -> None:
@@ -367,6 +378,7 @@ class DeviceController(QObject):
self._save_status()
@Slot(str)
@slot_error_boundary
def eraseMemory(self, port: str) -> None:
"""Send p1 erase command."""
self._set_operation_progress(True)
@@ -385,6 +397,7 @@ class DeviceController(QObject):
self._set_operation_progress(False)
@Slot(str)
@slot_error_boundary
def readDebug(self, port: str) -> None:
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
@@ -428,6 +441,7 @@ class DeviceController(QObject):
self._set_operation_progress(False)
@Slot(str, str)
@slot_error_boundary
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
"""Parse raw bytes into temperatures and save to CSV.
@@ -14,6 +14,7 @@ from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_dat
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel
from pygui.backend.models.threshold_classifier import ThresholdConfig
from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader
@@ -182,6 +183,7 @@ class SessionController(QObject):
# ---- mode + thresholds ----
@Slot(str)
@slot_error_boundary
def setMode(self, mode: str) -> None:
if mode == self._mode:
return
@@ -191,6 +193,7 @@ class SessionController(QObject):
self.modeChanged.emit()
@Slot(float, float, bool)
@slot_error_boundary
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
if self._last: # re-band current frame in place
@@ -198,6 +201,7 @@ class SessionController(QObject):
# ---- review: file load + playback ----
@Slot(str)
@slot_error_boundary
def loadFile(self, file_path: str) -> None:
from pathlib import Path
@@ -250,30 +254,37 @@ class SessionController(QObject):
self._emit_current()
@Slot()
@slot_error_boundary
def play(self) -> None:
self._play_timer.start(self._next_interval_ms())
@Slot()
def pause(self) -> None: self._play_timer.stop()
@slot_error_boundary
def pause(self) -> None:
self._play_timer.stop()
@Slot()
@slot_error_boundary
def stop(self) -> None:
self._play_timer.stop()
self._player.seek(0)
self._emit_current()
@Slot(int)
@slot_error_boundary
def step(self, delta: int) -> None:
self._play_timer.stop()
self._player.step(delta)
self._emit_current()
@Slot(int)
@slot_error_boundary
def seek(self, i: int) -> None:
self._player.seek(i)
self._emit_current()
@Slot(float)
@slot_error_boundary
def setSpeed(self, x: float) -> None:
self._speed = max(0.1, x)
if self._play_timer.isActive():
@@ -325,6 +336,7 @@ class SessionController(QObject):
# ---- live: stream start/stop ----
@Slot(str, str)
@slot_error_boundary
def startStream(self, port: str, family_code: str = "") -> None:
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
@@ -389,6 +401,7 @@ class SessionController(QObject):
self.stateChanged.emit()
@Slot()
@slot_error_boundary
def stopStream(self) -> None:
self._repaint_timer.stop()
if self._reader:
@@ -409,6 +422,7 @@ class SessionController(QObject):
self.stopRecording()
@Slot(object)
@slot_error_boundary
def _on_live_frame(self, frame: Frame) -> None:
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
self._last_raw_frame = frame
@@ -428,35 +442,41 @@ class SessionController(QObject):
# ---- 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()
+29
View File
@@ -0,0 +1,29 @@
import logging
from functools import wraps
def slot_error_boundary(func):
"""Wrap a @Slot method so exceptions are logged + signalled to QML.
Usage (stack under @Slot):
@Slot()
@slot_error_boundary
def myMethod(self) -> None:
...
On exception:
- Logs full traceback via logger.exception()
- Emits logMessage signal with human-readable error (if available)
- Returns None so QML never sees a crash
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
try:
return func(self, *args, **kwargs)
except Exception as e:
logger = logging.getLogger(self.__class__.__module__)
logger.exception("Slot '%s' raised: %s", func.__name__, e)
if hasattr(self, "logMessage"):
self.logMessage.emit(f"Error in {func.__name__}: {str(e)}")
return None
return wrapper