Skip to content

Commit 9171d8c

Browse files
committed
added ClientStation class in client/proxy, which is a light weight container of proxy instruments.
- added a gui for the ClientStation, which can be used on the client side for controlling instruments like in the server gui. - added a `QtListener` in `minitoring/gui` to work in the ClientStationGui - added test/example codes in `testing/test_async_requests` for testing these new features
1 parent 361bddb commit 9171d8c

File tree

14 files changed

+2406
-1390
lines changed

14 files changed

+2406
-1390
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
from .core import sendRequest
2-
from .proxy import ProxyInstrument, Client, QtClient, SubClient
2+
from .proxy import ProxyInstrument, Client, QtClient, SubClient, ClientStation
33

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
from typing import Optional, Union
2+
import sys
3+
import json
4+
import fnmatch
5+
import logging
6+
import re
7+
from html import escape
8+
9+
import yaml
10+
from qcodes import Instrument
11+
from qtpy.QtWidgets import QFileDialog, QMenu, QWidget, QSizePolicy, QSplitter
12+
from qtpy.QtGui import QGuiApplication
13+
from qtpy.QtCore import Qt
14+
15+
from instrumentserver import QtCore, QtWidgets, QtGui, getInstrumentserverPath
16+
from instrumentserver.client import QtClient, Client, ClientStation
17+
from instrumentserver.gui.instruments import GenericInstrument
18+
from instrumentserver.gui.misc import DetachableTabWidget
19+
from instrumentserver.log import LogLevels, LogWidget, log
20+
from instrumentserver.log import logger as get_instrument_logger
21+
from instrumentserver.server.application import StationList, StationObjectInfo
22+
from instrumentserver.blueprints import ParameterBroadcastBluePrint
23+
from instrumentserver.monitoring.listener import QtListener
24+
25+
# instrument class key in configuration files for configurations that will be applied to all instruments
26+
DEFAULT_INSTRUMENT_KEY = "__default__"
27+
28+
logger = get_instrument_logger()
29+
logger.setLevel(logging.INFO)
30+
31+
32+
class ServerWidget(QtWidgets.QWidget):
33+
def __init__(self, client_station:ClientStation, parent=None):
34+
super().__init__(parent)
35+
self.client_station = client_station
36+
37+
# ---- Form (host/port + label for command) ----
38+
form = QWidget(self)
39+
form_layout = QtWidgets.QFormLayout(form)
40+
form_layout.setContentsMargins(0, 0, 0, 0)
41+
form_layout.setSpacing(8)
42+
form_layout.setLabelAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
43+
form_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow)
44+
45+
# Non-editable
46+
self.host = QtWidgets.QLineEdit(self.client_station._host)
47+
self.host.setReadOnly(True)
48+
self.port = QtWidgets.QLineEdit(str(self.client_station._port))
49+
self.port.setReadOnly(True)
50+
51+
self._tint_readonly(self.host)
52+
self._tint_readonly(self.port)
53+
54+
# Command editor
55+
self.cmd = QtWidgets.QPlainTextEdit()
56+
self.cmd.setPlaceholderText("THIS IS NOT WORKING YET!!!")
57+
self.cmd.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
58+
self.cmd.setFont(QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont))
59+
rows = 6
60+
lh = self.cmd.fontMetrics().lineSpacing()
61+
self.cmd.setFixedHeight(lh * rows + 2 * self.cmd.frameWidth() + 8)
62+
# Let it grow horizontally
63+
self.cmd.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
64+
65+
form_layout.addRow("Host:", self.host)
66+
form_layout.addRow("Port:", self.port)
67+
68+
# todo: Remote server calls to be implemented
69+
# form_layout.addRow(QtWidgets.QLabel("Start Server Command:"))
70+
# form_layout.addRow(self.cmd)
71+
72+
# ---- Buttons
73+
# restart_button = QtWidgets.QPushButton("Restart")
74+
# restart_button.clicked.connect(self.restart_server)
75+
76+
# btns = QtWidgets.QHBoxLayout()
77+
# btns.addWidget(restart_button)
78+
79+
# ---- Main layout ----
80+
main = QtWidgets.QVBoxLayout(self)
81+
main.setContentsMargins(6, 6, 6, 6)
82+
main.addWidget(form)
83+
# main.addLayout(btns)
84+
main.addStretch(1)
85+
86+
def _tint_readonly(self, le, bg="#f3f6fa"):
87+
pal = le.palette()
88+
pal.setColor(QtGui.QPalette.Base, QtGui.QColor(bg))
89+
le.setPalette(pal)
90+
91+
# def restart_server(self):
92+
# todo: to be implemented, ssh to server pc and start the server there.
93+
# need to close the port if occupied.
94+
# print(self.cmd.toPlainText())
95+
96+
97+
98+
class ClientStationGui(QtWidgets.QMainWindow):
99+
def __init__(self, station: ClientStation, hide_config:Union[str, dict]=None):
100+
"""
101+
GUI frontend for viewing and managing instruments in a ClientStation.
102+
103+
:param station: An instance of ClientStation containing proxy instruments.
104+
:param hide_config: Dict or path to a yaml file that specifies the parameters and methods to hide for each
105+
instrument class, keyed by the instrument class names.
106+
"""
107+
super().__init__()
108+
self.setWindowTitle("Instrument Client GUI")
109+
# Set unique Windows App ID so that this app can have separate taskbar entry than other Qt apps
110+
if sys.platform == "win32":
111+
import ctypes
112+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("InstrumentServer.ClientStation")
113+
self.setWindowIcon(QtGui.QIcon(getInstrumentserverPath("resource","icons")+"/client_app_icon.svg"))
114+
self.station = station
115+
self.cli = station.client
116+
117+
# set up the listener thread and worker that listens to update messages emitted by the server (from all clients)
118+
self.listenerThread = QtCore.QThread()
119+
broadcast_addr = self.cli.addr[:-1] + str(int(self.cli.addr[-1])+1) # broadcast port is by default +1
120+
self.listener = QtListener([broadcast_addr])
121+
self.listener.moveToThread(self.listenerThread)
122+
self.listenerThread.started.connect(self.listener.run)
123+
self.listener.finished.connect(self.listenerThread.quit)
124+
self.listener.finished.connect(self.listener.deleteLater)
125+
self.listener.finished.connect(self.listenerThread.deleteLater)
126+
self.listener.serverSignal.connect(self.listenerEvent)
127+
self.listenerThread.start()
128+
129+
# expand hide config
130+
if isinstance(hide_config, str):
131+
with open(hide_config, 'r') as f:
132+
self.hide_config = yaml.safe_load(f)
133+
else:
134+
self.hide_config = hide_config
135+
136+
if self.hide_config is None:
137+
self.hide_config = {}
138+
139+
self.instrumentTabsOpen = {}
140+
141+
# --- main tabs
142+
self.tabs = DetachableTabWidget()
143+
self.setCentralWidget(self.tabs)
144+
self.tabs.onTabClosed.connect(self.onTabDeleted)
145+
self.tabs.currentChanged.connect(self.onTabChanged)
146+
147+
# --- client station
148+
self.stationList = StationList() # instrument list
149+
self.stationObjInfo = StationObjectInfo() # instrument docs
150+
151+
for inst in self.station.instruments.values():
152+
self.stationList.addInstrument(inst.bp)
153+
self.stationList.componentSelected.connect(self._displayComponentInfo)
154+
self.stationList.itemDoubleClicked.connect(self.openInstrumentTab)
155+
self.stationList.closeRequested.connect(self.closeInstrument)
156+
157+
stationWidgets = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
158+
stationWidgets.addWidget(self.stationList)
159+
stationWidgets.addWidget(self.stationObjInfo)
160+
stationWidgets.setSizes([200, 500])
161+
162+
self.tabs.addUnclosableTab(stationWidgets, 'Station')
163+
164+
self.addParameterLoadSaveToolbar()
165+
166+
# --- log widget
167+
self.log_widget = LogWidget(level=logging.INFO)
168+
self.tabs.addUnclosableTab(self.log_widget, 'Log')
169+
170+
# --- server widget
171+
self.server_widget = ServerWidget(self.station)
172+
self.tabs.addUnclosableTab(self.server_widget, 'Server')
173+
174+
175+
# adjust window size
176+
screen_geometry = QGuiApplication.primaryScreen().availableGeometry()
177+
width = int(screen_geometry.width() * 0.3) # 30% of screen width
178+
height = int(screen_geometry.height() * 0.7) # 70% of screen height
179+
self.resize(width, height)
180+
181+
@QtCore.Slot(ParameterBroadcastBluePrint)
182+
def listenerEvent(self, message: ParameterBroadcastBluePrint):
183+
if message.action == 'parameter-update':
184+
logger.info(f"{message.action}: {message.name}: {message.value}")
185+
186+
def openInstrumentTab(self, item: QtWidgets.QListWidgetItem, index: int):
187+
"""
188+
Gets called when the user double clicks and item of the instrument list.
189+
Adds a new generic instrument GUI window to the tab bar.
190+
If the tab already exists switches to that one.
191+
"""
192+
name = item.text(0)
193+
if name not in self.instrumentTabsOpen:
194+
instrument = self.station.get_instrument(name)
195+
hide_dict = self._parse_hide_attributes(instrument)
196+
ins_widget = GenericInstrument(instrument, self, sub_host=self.cli.host, sub_port=self.cli.port+1,
197+
**hide_dict)
198+
199+
# add tab
200+
ins_widget.setObjectName(name)
201+
index = self.tabs.addTab(ins_widget, name)
202+
self.tabs.setCurrentIndex(index)
203+
self.instrumentTabsOpen[name] = ins_widget
204+
205+
elif name in self.instrumentTabsOpen:
206+
self.tabs.setCurrentWidget(self.instrumentTabsOpen[name])
207+
208+
@QtCore.Slot(str)
209+
def _displayComponentInfo(self, name: Union[str, None]):
210+
if name is not None:
211+
bp = self.station[name].bp
212+
else:
213+
bp = None
214+
self.stationObjInfo.setObject(bp)
215+
216+
def _parse_hide_attributes(self, instrument:Instrument):
217+
"""
218+
parse the parameters and methods to hide
219+
"""
220+
221+
# get instrument class name
222+
if hasattr(instrument, 'bp'):
223+
cls_name = instrument.bp.instrument_module_class
224+
cls_name = cls_name.split(".")[-1]
225+
else:
226+
cls_name = instrument.__class__.__name__
227+
228+
# get hide list and expand wildcards
229+
ins_hide_patterns = self.hide_config.get(cls_name, [])
230+
default_hide_patterns = self.hide_config.get(DEFAULT_INSTRUMENT_KEY, [])
231+
hide_patterns = set(ins_hide_patterns + default_hide_patterns)
232+
233+
# get all parameter and method names
234+
params = instrument.parameters.keys()
235+
methods = instrument.functions.keys()
236+
submodules = instrument.submodules.keys()
237+
238+
# expand wildcards and find matching items to hide
239+
params_hide = set()
240+
methods_hide = set()
241+
submodules_hide = set()
242+
for pattern in hide_patterns:
243+
params_hide.update(fnmatch.filter(params, pattern))
244+
methods_hide.update(fnmatch.filter(methods, pattern))
245+
submodules_hide.update(fnmatch.filter(submodules, pattern))
246+
247+
# get submodule parameters and functions to hide
248+
for sm in submodules_hide: # assuming no submodule in submodules for now...
249+
params_hide.update([sm + "." + k for k in instrument.submodules[sm].parameters.keys()])
250+
methods_hide.update([sm + "." + k for k in instrument.submodules[sm].functions.keys()])
251+
252+
hide_dict = {'parameters-hide': list(params_hide), 'methods-hide': list(methods_hide)}
253+
return hide_dict
254+
255+
@QtCore.Slot(int)
256+
def onTabChanged(self, index):
257+
widget = self.tabs.widget(index)
258+
# if instrument tab is not in 'instrumentTabsOpen' yet, tab must be just open, in this case the constructor
259+
# of the parameter widget should have already called refresh, so we don't have to do that again.
260+
if hasattr(widget, "parametersList") and (widget.objectName() in self.instrumentTabsOpen):
261+
widget.parametersList.model.refreshAll()
262+
263+
@QtCore.Slot(str)
264+
def onTabDeleted(self, name: str) -> None:
265+
if name in self.instrumentTabsOpen:
266+
del self.instrumentTabsOpen[name]
267+
268+
def addParameterLoadSaveToolbar(self):
269+
# --- toolbar basics ---
270+
self.toolBar = QtWidgets.QToolBar("Params", self)
271+
self.toolBar.setIconSize(QtCore.QSize(22, 22))
272+
self.toolBar.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
273+
self.addToolBar(self.toolBar)
274+
275+
# --- composite path widget
276+
pathWidget = QtWidgets.QWidget(self.toolBar)
277+
pathLayout = QtWidgets.QHBoxLayout(pathWidget)
278+
pathLayout.setContentsMargins(0, 0, 0, 0)
279+
pathLayout.setSpacing(6)
280+
281+
lbl = QtWidgets.QLabel("Params:", pathWidget)
282+
283+
self.paramPathEdit = QtWidgets.QLineEdit(pathWidget)
284+
self.paramPathEdit.setPlaceholderText("Parameter file path")
285+
self.paramPathEdit.setClearButtonEnabled(True)
286+
self.paramPathEdit.setMinimumWidth(280)
287+
h = self.paramPathEdit.fontMetrics().height() + 10
288+
self.paramPathEdit.setFixedHeight(h)
289+
self.paramPathEdit.setTextMargins(6, 0, 6, 0)
290+
291+
if self.station.param_path:
292+
self.paramPathEdit.setText(self.station.param_path)
293+
294+
pathLayout.addWidget(lbl)
295+
pathLayout.addWidget(self.paramPathEdit, 1) # stretch
296+
297+
pathAction = QtWidgets.QWidgetAction(self.toolBar)
298+
pathAction.setDefaultWidget(pathWidget)
299+
self.toolBar.addAction(pathAction)
300+
301+
# --- actions ---
302+
browseBtn = QtWidgets.QAction(QtGui.QIcon(":/icons/folder.svg"), "Browse", self)
303+
browseBtn.triggered.connect(self.browseParamPath)
304+
loadAct = QtWidgets.QAction(QtGui.QIcon(":/icons/load.svg"), "Load", self)
305+
saveAct = QtWidgets.QAction(QtGui.QIcon(":/icons/save.svg"), "Save", self)
306+
loadAct.triggered.connect(self.loadParams)
307+
saveAct.triggered.connect(self.saveParams)
308+
309+
self.toolBar.addAction(browseBtn)
310+
self.toolBar.addAction(loadAct)
311+
self.toolBar.addAction(saveAct)
312+
313+
# enter to load
314+
self.paramPathEdit.returnPressed.connect(self.loadParams)
315+
316+
@QtCore.Slot()
317+
def browseParamPath(self):
318+
filePath, _ = QFileDialog.getOpenFileName(
319+
self, "Select Parameter File", ".", "JSON Files (*.json);;All Files (*)"
320+
)
321+
if filePath:
322+
self.paramPathEdit.setText(filePath)
323+
324+
@QtCore.Slot()
325+
def saveParams(self):
326+
file_path = self.paramPathEdit.text()
327+
if not file_path:
328+
QtWidgets.QMessageBox.warning(self, "No file path", "Please specify a path to save parameters.")
329+
return
330+
try:
331+
self.station.save_parameters(file_path)
332+
logger.info(f"Saved parameters to {file_path}")
333+
except Exception as e:
334+
QtWidgets.QMessageBox.critical(self, "Save Error", str(e))
335+
336+
@QtCore.Slot()
337+
def loadParams(self):
338+
file_path = self.paramPathEdit.text()
339+
if not file_path:
340+
QtWidgets.QMessageBox.warning(self, "No file path", "Please specify a path to load parameters.")
341+
return
342+
try:
343+
self.station.load_parameters(file_path)
344+
logger.info(f"Loaded parameters from {file_path}")
345+
346+
# Refresh all tabs
347+
for i in range(self.tabs.count()):
348+
widget = self.tabs.widget(i)
349+
if hasattr(widget, 'parametersList') and hasattr(widget.parametersList, 'model'):
350+
widget.parametersList.model.refreshAll()
351+
352+
except Exception as e:
353+
QtWidgets.QMessageBox.critical(self, "Load Error", str(e))
354+
355+
356+
@QtCore.Slot(str)
357+
def closeInstrument(self, name: str):#, item: QtWidgets.QListWidgetItem):
358+
try:
359+
# close instrument on server
360+
self.station.close_instrument(name)
361+
except Exception as e:
362+
QtWidgets.QMessageBox.critical(self, "Close Error", f"Failed to close '{name}':\n{e}")
363+
return
364+
365+
# remove from gui
366+
self.removeInstrumentFromGui(name)
367+
368+
logger.info(f"Closed instrument '{name}'")
369+
370+
371+
def removeInstrumentFromGui(self, name: str):
372+
"""Remove an instrument from the station list."""
373+
self.stationList.removeObject(name)
374+
self.stationObjInfo.clear()
375+
if name in self.instrumentTabsOpen:
376+
self.tabs.removeTab(self.tabs.indexOf(self.instrumentTabsOpen[name]))
377+
del self.instrumentTabsOpen[name]

0 commit comments

Comments
 (0)