refactor: implement thread-safe hardware operations, port caching, and refined state management in the device controller

This commit is contained in:
jack
2026-07-02 22:33:44 -07:00
parent da43aeb1f6
commit 9e28a25695
7 changed files with 193 additions and 68 deletions
@@ -39,6 +39,9 @@ class DeviceController(QObject):
# ---- public signals ----
portsUpdated = Signal(list)
connectionStatusChanged = Signal()
operationInProgressChanged = Signal()
selectedPortChanged = Signal()
detectResult = Signal(object) # WaferInfo dict or None
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
eraseResult = Signal(dict) # {"success": bool}
@@ -51,6 +54,8 @@ class DeviceController(QObject):
# ---- private signals (marshal worker-thread results back to main thread) ----
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
_eraseFinished = Signal(bool) # (success)
_debugFinished = Signal(dict) # (result_dict)
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
super().__init__(parent)
@@ -79,6 +84,8 @@ class DeviceController(QObject):
# Marshal worker-thread results onto the Qt main thread.
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
self._eraseFinished.connect(self._handle_erase_finished, Qt.ConnectionType.QueuedConnection)
self._debugFinished.connect(self._handle_debug_finished, Qt.ConnectionType.QueuedConnection)
root_logger = logging.getLogger()
root_logger.addHandler(QmlActivityLogHandler(self))
@@ -94,14 +101,14 @@ class DeviceController(QObject):
"""Update connection status and notify QML."""
if self._connection_status != status:
self._connection_status = status
self.portsUpdated.emit(self.availablePorts)
self.connectionStatusChanged.emit()
@Property(str, notify=portsUpdated)
@Property(str, notify=connectionStatusChanged)
def connectionStatus(self) -> str:
"""Current connection status string for display."""
return self._connection_status
@Property(bool, notify=portsUpdated)
@Property(bool, notify=operationInProgressChanged)
def operationInProgress(self) -> bool:
"""Whether a hardware operation is currently running."""
return self._operation_in_progress
@@ -119,7 +126,7 @@ class DeviceController(QObject):
self._append_log(f"Save data dir set to: {path}")
self._save_status()
@Property(str, notify=portsUpdated)
@Property(str, notify=selectedPortChanged)
def selectedPort(self) -> str:
"""Currently selected serial port shared across the UI."""
return self._selected_port
@@ -128,8 +135,10 @@ class DeviceController(QObject):
@slot_error_boundary
def setSelectedPort(self, port: str) -> None:
"""Set the active serial port from the port selector."""
self._selected_port = port
self._save_status()
if self._selected_port != port:
self._selected_port = port
self.selectedPortChanged.emit()
self._save_status()
@Property(int, notify=parsedDataReady)
def dataRowCount(self) -> int:
@@ -243,14 +252,10 @@ class DeviceController(QObject):
def _set_operation_progress(self, in_progress: bool) -> None:
"""Set operation progress state and notify QML.
portsUpdated is the shared notify signal for availablePorts,
connectionStatus, and operationInProgress — emitting it forces
QML to re-evaluate all three bindings.
"""
self._operation_in_progress = in_progress
self.portsUpdated.emit(self._service.enumerate_ports())
"""Set operation progress state and notify QML."""
if self._operation_in_progress != in_progress:
self._operation_in_progress = in_progress
self.operationInProgressChanged.emit()
# ---- Public Slots ----
@@ -258,6 +263,7 @@ class DeviceController(QObject):
@slot_error_boundary
def refreshPorts(self) -> None:
"""Scan for available serial ports and emit updated list."""
self._service.clear_port_scan_cache()
ports = self._service.enumerate_ports()
self._set_connection_status("Disconnected")
self.portsUpdated.emit(ports)
@@ -396,12 +402,33 @@ class DeviceController(QObject):
@slot_error_boundary
def eraseMemory(self, port: str) -> None:
"""Send p1 erase command."""
if self._operation_in_progress:
self._append_log("Already busy — ignoring erase request")
return
self._set_operation_progress(True)
self._set_connection_status("Erasing...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
ok = self._service.erase_wafer(port)
threading.Thread(
target=self._erase_worker,
args=(port,),
daemon=True,
).start()
def _erase_worker(self, port: str) -> None:
"""Background thread worker for eraseMemory."""
try:
ok = self._service.erase_wafer(port)
except Exception as exc:
log.exception("Erase worker crashed: %s", exc)
ok = False
self._eraseFinished.emit(ok)
@Slot(bool)
@slot_error_boundary
def _handle_erase_finished(self, ok: bool) -> None:
"""Main thread completion handler for eraseMemory."""
if ok:
self._set_connection_status("Connected")
self._append_log("Erase command accepted — wait ~15 seconds")
@@ -414,45 +441,63 @@ class DeviceController(QObject):
@Slot(str)
@slot_error_boundary
def readDebug(self, port: str) -> None:
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
Emits debugResult with counts on success.
"""
"""Read both D1 (sensor) and F1 (debug/cold-junction) data."""
if self._operation_in_progress:
self._append_log("Already busy — ignoring debug read request")
return
self._set_operation_progress(True)
self._set_connection_status("Reading debug...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Reading debug data on {port} ...")
# Step 1: D1 sensor data
sensor_data = self._service.read_wafer_data(port, family_code="")
if sensor_data is None:
threading.Thread(
target=self._debug_worker,
args=(port,),
daemon=True,
).start()
def _debug_worker(self, port: str) -> None:
"""Background thread worker for readDebug."""
try:
# Step 1: D1 sensor data
sensor_data = self._service.read_wafer_data(port, family_code="")
if sensor_data is None:
self._debugFinished.emit({"error": "D1 read failed"})
return
# Step 2: F1 debug data
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
if debug_data is None:
self._debugFinished.emit({"error": "F1 read failed"})
return
self._debugFinished.emit({
"success": True,
"sensor_bytes": len(sensor_data),
"debug_bytes": len(debug_data),
})
except Exception as exc:
log.exception("Debug worker crashed: %s", exc)
self._debugFinished.emit({"error": str(exc)})
@Slot(dict)
@slot_error_boundary
def _handle_debug_finished(self, result: dict) -> None:
"""Main thread completion handler for readDebug."""
if result.get("success"):
self._set_connection_status("Connected")
sensor_bytes = result.get("sensor_bytes", 0)
debug_bytes = result.get("debug_bytes", 0)
self._append_log(
f"Debug read complete: {sensor_bytes} sensor bytes, "
f"{debug_bytes} debug bytes"
)
else:
self._set_connection_status("Disconnected")
self._append_log("D1 read failed — aborting debug read")
self.debugResult.emit({"error": "D1 read failed"})
self._set_operation_progress(False)
return
err_msg = result.get("error", "Debug read failed")
self._append_log(err_msg)
self._append_log(f"D1 read: {len(sensor_data)} bytes")
# Step 2: F1 debug data
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
if debug_data is None:
self._set_connection_status("Disconnected")
self._append_log("F1 read failed")
self.debugResult.emit({"error": "F1 read failed"})
self._set_operation_progress(False)
return
self._set_connection_status("Connected")
self._append_log(
f"Debug read complete: {len(sensor_data)} sensor bytes, "
f"{len(debug_data)} debug bytes"
)
self.debugResult.emit({
"success": True,
"sensor_bytes": len(sensor_data),
"debug_bytes": len(debug_data),
})
self.debugResult.emit(result)
self._set_operation_progress(False)
@Slot(str, str)
@@ -232,7 +232,8 @@ class SessionController(QObject):
def setMode(self, mode: str) -> None:
if mode == self._mode:
return
self.stopStream()
if mode != "live":
self.stopStream()
self._play_timer.stop()
self._mode = mode
self.modeChanged.emit()
@@ -394,6 +395,9 @@ class SessionController(QObject):
@Slot(str, str)
@slot_error_boundary
def startStream(self, port: str, family_code: str = "") -> None:
if self._reader is not None:
log.warning("startStream: StreamReader is already running.")
return
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
from pygui.serialcomm.serial_port import SerialPort # transport open
@@ -449,13 +453,24 @@ class SessionController(QObject):
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
transport.write(cmd)
import weakref
weak_self = weakref.ref(self)
def on_error(exc: Exception):
log.error("Live stream error: %s", exc)
self._liveError.emit()
ref = weak_self()
if ref is not None:
ref._liveError.emit()
ref.stopStream()
def on_frame(frame: Frame):
ref = weak_self()
if ref is not None:
ref._liveFrame.emit(frame)
self._reader = StreamReader(
transport, parse_binary_frame,
on_frame=lambda f: self._liveFrame.emit(f),
on_frame=on_frame,
on_error=on_error,
family_code=family_code or "A")
self._reader.start()
@@ -474,10 +489,9 @@ class SessionController(QObject):
self.liveStatsChanged.emit()
if self._reader:
transport = self._reader._transport
self._reader.stop()
self._reader = None
# Send 'D2S' command padded to 512 bytes to stop the stream
# Send BEFORE stopping the reader which will close the port on thread exit
if transport and transport.is_open:
try:
cmd = b"D2S" + (b"F" * 509)
@@ -486,6 +500,9 @@ class SessionController(QObject):
except Exception as exc:
log.error("Error sending stop command: %s", exc)
self._reader.stop()
self._reader = None
self.stateChanged.emit()
self.stopRecording()