I'm writing a GUI in PyQT to control some hardware. I've had a lot of problems with one particular instrument that I'm accessing through a company provided dll. It seems like something in the dll impacts the way Qt processes, but I don't understand how it works well.
In this case, I'm trying make a simple troubleshooting program that is periodically asking the equipment for its status based on a QTimer. I also have a button that runs an example "long process". I move these operations to a separate thread to not block the GUI. Something about this dll makes it such that I cannot connect to the equipment from multiple threads. I create this "Eqpt_Thread" and connect it to the main window. I find that the function connected to the timer can outpace itself occasionally. In the "NKT_System" class, I wrote an "is_busy" flag to try to block simultaneously operations from multiple threads (I also tried this using QMutex and has the same issues). I print out the status of the is_busy attribute going in and out of each method call within "NKT_System", and I find that the "long process" function is being called in the middle of the "get_eqpt_status" function execution (or vice versa). This causes a never-ending loop with my is_busy check since "long_process" is acting in the middle of "get_eqpt_status".
My issue is, "long_process" should be waiting for the currently running function "get_eqpt_status" to finish. This is operating on a single thread, as confirmed by the printed "QThread.currentThread()" lines. How can "long_process" be interrupting execution of "get_eqpt_status" on a single thread?
Note, I created a dummy program that replaces "get_eqpt_status" with print(1); print(2) and "long_process" with print('a'); time.sleep(4); print('b'), and I get the expected output 1, 2, a, b, 1, 2. When I'm using this equipment connection code, it's like I'm getting 1, 2, a, 1 - then everything crashes because of this unexpected behavior.
I've also tried not using Qt.QueuedConnection, though I figured that would help ensure I'm not overriding the EventLoop Queue when I click the button. Also I tried moving the QTimer that triggers get_eqpt_status into the Eqpt_Controller class, but that didn't give different results.
Also, there is some C++ example code provided with the DLL that shows use of the Qt class they use which gives me errors when multithreading. I see in the C++ example that they are creating local QEventLoop's. I wonder if that could be related, but I don't understand Qt or what is being provided in the DLL enough to know.
Code:
import sys
import time
from PyQt5.QtCore import (QObject, Qt, QTimer, pyqtSignal, pyqtSlot, QThread)
from PyQt5.QtWidgets import (QApplication, QLabel, QMainWindow,
QPushButton, QGridLayout, QWidget)
from catalight.equipment.light_sources import nkt_system
class MainWindow(QMainWindow):
"""Subclass QMainWindow to customize your application's main window."""
def __init__(self):
super().__init__()
print('Main window on:\t', QThread.currentThread())
self.layout = QGridLayout()
self.widget = QWidget() # Main widget
# Create some widgets in GUI
self.statusLabel1 = QLabel("Status Line 1:")
self.statusLabel2 = QLabel("Status Line 2:")
self.statusLabel3 = QLabel("Current Time:")
self.statusLine1 = QLabel("-")
self.statusLine2 = QLabel("-")
self.statusLine3 = QLabel("-")
self.manualCtrlBut = QPushButton("Start Long Process")
# Place widgets in a layout
self.layout.addWidget(self.statusLabel1, 0, 0)
self.layout.addWidget(self.statusLabel2, 1, 0)
self.layout.addWidget(self.statusLabel3, 2, 0)
self.layout.addWidget(self.statusLine1, 0, 1)
self.layout.addWidget(self.statusLine2, 1, 1)
self.layout.addWidget(self.statusLine3, 2, 1)
self.layout.addWidget(self.manualCtrlBut, 3, 0, 1, 2)
# Add layout to window
self.widget.setLayout(self.layout)
self.setCentralWidget(self.widget)
# Create dedicated equipment thread
self.eqpt_thread = QThread()
self.eqpt_controller = Eqpt_Controller()
self.eqpt_controller.moveToThread(self.eqpt_thread)
# Initialize Eqpt once new thread opens
self.eqpt_thread.started.connect(self.eqpt_controller.connect_to_hardware)
# Connect signals/slots
self.eqpt_controller.eqpt_status_signal.connect(self.display_eqpt_status)
self.manualCtrlBut.clicked.connect(self.eqpt_controller.long_process, Qt.QueuedConnection)
# Start new thread
self.eqpt_thread.start()
# Create a timer and start it once hardware is initialized
self.timer = QTimer(self)
self.timer.timeout.connect(self.display_time)
self.timer.timeout.connect(self.eqpt_controller.get_eqpt_status, Qt.QueuedConnection)
self.eqpt_controller.eqpt_connection_finished.connect(lambda: self.timer.start(1000))
self.show()
@pyqtSlot("PyQt_PyObject")
def display_eqpt_status(self, eqpt_status):
self.statusLine1.setText(str(eqpt_status['short setpoint']))
self.statusLine2.setText(str(eqpt_status['long setpoint']))
def display_time(self):
# Get the current time in seconds since the epoch
current_time_seconds = time.time()
# Convert the time to a struct_time
time_struct = time.localtime(current_time_seconds)
# Format the time as HH:MM:SS AM/PM
formatted_time = time.strftime("%I:%M:%S %p", time_struct)
# Print formatted time to GUI
self.statusLine3.setText(formatted_time)
class Eqpt_Controller(QObject):
eqpt_status_signal = pyqtSignal(dict)
eqpt_connection_finished = pyqtSignal()
def __init__(self, parent=None):
super().__init__()
print('Controller Init on:\t', QThread.currentThread())
@pyqtSlot()
def connect_to_hardware(self):
print('run method is being called')
print('Hardware Connection on:\t', QThread.currentThread())
self.laser = nkt_system.NKT_System() # initilize hardware
self.eqpt_connection_finished.emit() # notify GUI
return
@pyqtSlot()
def get_eqpt_status(self):
print('Eqpt read on:\t', QThread.currentThread())
# Read equipment status (These will print status)
center = self.laser.central_wavelength
bandwidth = self.laser.bandwidth
# Compute values and save to dict, emit values to GUI
short_setpoint = center - bandwidth/2
long_setpoint = center + bandwidth/2
eqpt_status = {'short setpoint': short_setpoint,
'long setpoint': long_setpoint}
self.eqpt_status_signal.emit(eqpt_status)
return
@pyqtSlot()
def long_process(self):
print('Long process on:\t', QThread.currentThread())
print('starting long process...')
# This will print status for debugging
self.laser.print_output()
print('finished')
return
if __name__ == "__main__":
# Main
app = QApplication(sys.argv)
window = MainWindow()
sys.exit(app.exec_())
Output of GUI after pressing button:
Loading x64 DLL from: C:\src\repos\nkt_tools\nkt_tools\NKTPDLL\x64\NKTPDLL.dll
Main window on: <PyQt5.QtCore.QThread object at 0x000002355C460820>
Controller Init on: <PyQt5.QtCore.QThread object at 0x000002355C460E50>
run method is being called
Hardware Connection on: <PyQt5.QtCore.QThread object at 0x000002355C460D30>
Searching for connected NKT Laser...
NKT Extreme/Fianium Found:
Comport: COM8 Device type: 0x60 at address: 15
System Type = SuperK Extreme
Inlet Temperature = 22.9 C
Searching for connected NKT Varia...
NKT Varia Found:
Comport: COM8 Device type: 0x68 at address: 17
Last laser calibration was:
2023-12-20
Eqpt read on: <PyQt5.QtCore.QThread object at 0x000002355C460D30>
going into central wavelength is_busy = False
leaving central wavelength is_busy = False
going into bandwidth is_busy = False
leaving bandwidth is_busy = False
Long process on: <PyQt5.QtCore.QThread object at 0x000002355C460D30>
starting long process...
going into print output is_busy = False
Eqpt read on: <PyQt5.QtCore.QThread object at 0x000002355C460D30>
going into central wavelength is_busy = True
Notice how the last "going into central wavelength" line prints BEFORE the expected "finish" line from "long_process". This is causing my crashes