refactor: implement thread-safe hardware operations, port caching, and refined state management in the device controller
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user