From 1beac0639d597dd748c328b72bb3ce908cc0860f Mon Sep 17 00:00:00 2001 From: CHAO ZHOU Date: Mon, 10 Nov 2025 02:40:16 -0500 Subject: [PATCH 1/2] bug fixes and cosmetic tweaks - Fixed a crash in `gui/base_instrument/InstrumentSortFilterProxyModel` that occurred when "trashed" parameters are "hidden" and the "refreshAll" button was clicked. - Removed duplicate parameter update calls in `gui/instruments`. (can be tested with `dummy_instruments/generic.DummyInstrumentTimeout`) - Ensured the server address is passed correctly to the `SubClient` in `gui/instruments/ModelParameters`, so that parameters in the GUIs can still get the update signal properly when server address is not the default 'localhost:5555'. Useful when the subscriber is not on the server PC. - Made the "instrument type" (in `gui/instruments.GenericInstrument`) still display useful class name when the instrument is a proxy. - Made the gui display floating-point numbers in scientific notation in `gui/parameters` - Made the get-only paramters also update properly when `ParameterWidget` is initialized - updated `log.QLogHandler` to highlight parameter changes, also made the it thread-safe with a html signal-slot - Added some application icons --- instrumentserver/gui/base_instrument.py | 2 +- instrumentserver/gui/instruments.py | 49 ++- instrumentserver/gui/parameters.py | 27 +- instrumentserver/log.py | 92 ++++- .../resource/icons/server_app_icon.svg | 319 ++++++++++++++++++ instrumentserver/serialize.py | 2 +- instrumentserver/server/application.py | 9 +- .../testing/dummy_instruments/generic.py | 10 +- 8 files changed, 487 insertions(+), 23 deletions(-) create mode 100644 instrumentserver/resource/icons/server_app_icon.svg diff --git a/instrumentserver/gui/base_instrument.py b/instrumentserver/gui/base_instrument.py index c2136a8..8cc5c73 100644 --- a/instrumentserver/gui/base_instrument.py +++ b/instrumentserver/gui/base_instrument.py @@ -426,7 +426,7 @@ def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) - # Assertion is there to satisfy mypy. item can be None, that is why we check before making the assertion if item is not None: assert isinstance(item, ItemBase) - if self._isParentTrash(parent) or item.trash: + if self._isParentTrash(parent) or getattr(item, "trash", False): # item could be None when it's trashed and hidden return False return super().filterAcceptsRow(source_row, source_parent) diff --git a/instrumentserver/gui/instruments.py b/instrumentserver/gui/instruments.py index 56ffc9b..561d2ff 100644 --- a/instrumentserver/gui/instruments.py +++ b/instrumentserver/gui/instruments.py @@ -10,7 +10,7 @@ from . import parameters, keepSmallHorizontally from .base_instrument import InstrumentDisplayBase, ItemBase, InstrumentModelBase, InstrumentTreeViewBase, DelegateBase from .parameters import ParameterWidget, AnyInput, AnyInputForMethod -from .. import QtWidgets, QtCore, QtGui +from .. import QtWidgets, QtCore, QtGui, DEFAULT_PORT from ..blueprints import ParameterBroadcastBluePrint from ..client import ProxyInstrument, SubClient from ..helpers import stringToArgsAndKwargs, nestedAttributeFromString @@ -252,6 +252,14 @@ def createEditor(self, widget: QtWidgets.QWidget, option: QtWidgets.QStyleOption ret = ParameterWidget(element, widget) self.parameters[item.name] = ret + # Try to fetch and display current value immediately + # ---- Chao: removed because the constructor of ParameterWidget object already calls parameter get ---- + # if element.gettable: + # try: + # val = element.get() + # ret._setMethod(val) + # except Exception as e: + # logger.warning(f"Failed to get value for parameter {element.name}: {e}") return ret @@ -261,6 +269,9 @@ class ModelParameters(InstrumentModelBase): itemNewValue = QtCore.Signal(object, object) def __init__(self, *args, **kwargs): + # make sure we pass the server ip and port properly to the subscriber when the values are not defaults. + subClientArgs = {"sub_host": kwargs.pop("sub_host", 'localhost'), + "sub_port": kwargs.pop("sub_port", DEFAULT_PORT + 1)} super().__init__(*args, **kwargs) self.setColumnCount(3) @@ -268,7 +279,7 @@ def __init__(self, *args, **kwargs): # Live updates items self.cliThread = QtCore.QThread() - self.subClient = SubClient([self.instrument.name]) + self.subClient = SubClient([self.instrument.name], **subClientArgs) self.subClient.moveToThread(self.cliThread) self.cliThread.started.connect(self.subClient.connect) @@ -292,7 +303,8 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint): elif bp.action == 'parameter-update' or bp.action == 'parameter-call': item = self.findItems(fullName, QtCore.Qt.MatchExactly | QtCore.Qt.MatchRecursive, 0) if len(item) == 0: - self.addItem(fullName, element=nestedAttributeFromString(self.instrument, fullName)) + if fullName not in self.itemsHide: + self.addItem(fullName, element=nestedAttributeFromString(self.instrument, fullName)) else: assert isinstance(item[0], ItemBase) # The model can't actually modify the widget since it knows nothing about the view itself. @@ -331,7 +343,8 @@ def __init__(self, model, *args, **kwargs): def onItemNewValue(self, itemName, value): widget = self.delegate.parameters[itemName] try: - widget.paramWidget.setValue(value) + # use the abstract set method defined in parameter widget so it works for different types of widgets + widget._setMethod(value) except RuntimeError as e: logger.debug(f"Could not set value for {itemName} to {value}. Object is not being shown right now.") @@ -348,6 +361,12 @@ def __init__(self, instrument, viewType=ParametersTreeView, callSignals: bool = if 'parameters-hide' in kwargs: modelKwargs['itemsHide'] = kwargs.pop('parameters-hide') + # parameters for realtime update subscriber + if 'sub_host' in kwargs: + modelKwargs['sub_host'] = kwargs.pop('sub_host') + if 'sub_port' in kwargs: + modelKwargs['sub_port'] = kwargs.pop('sub_port') + super().__init__(instrument=instrument, attr='parameters', itemType=ItemParameters, @@ -636,6 +655,20 @@ def __init__(self, ins: Union[ProxyInstrument, Instrument], parent=None, **model self.ins = ins + if type(ins) is ProxyInstrument: + inst_type = "Proxy-" + ins.bp.instrument_module_class.split('.')[-1] + else: + inst_type = ins.__class__.__name__ + + ins_label = f'{ins.name} | type: {inst_type}' + + try: + # added a unique device_id if the instrument has that method + device_id = ins.device_id() + ins_label += f' | id: {device_id}' + except AttributeError: + pass + self._layout = QtWidgets.QVBoxLayout(self) self.setLayout(self._layout) @@ -644,9 +677,15 @@ def __init__(self, ins: Union[ProxyInstrument, Instrument], parent=None, **model self.parametersList = InstrumentParameters(instrument=ins, **modelKwargs) self.methodsList = InstrumentMethods(instrument=ins, **modelKwargs) - self.instrumentNameLabel = QtWidgets.QLabel(f'{self.ins.name} | type: {type(self.ins)}') + self.instrumentNameLabel = QtWidgets.QLabel(ins_label) self._layout.addWidget(self.instrumentNameLabel) self._layout.addWidget(self.splitter) self.splitter.addWidget(self.parametersList) self.splitter.addWidget(self.methodsList) + # self.parametersList.model.refreshAll() # Chao: removed as we will call that later in the constructor of the param widget + + # Resize param name, unit, and function name columns after entries loaded + self.parametersList.view.resizeColumnToContents(0) + self.parametersList.view.resizeColumnToContents(1) + self.methodsList.view.resizeColumnToContents(0) diff --git a/instrumentserver/gui/parameters.py b/instrumentserver/gui/parameters.py index 43ae67e..6f75f0e 100644 --- a/instrumentserver/gui/parameters.py +++ b/instrumentserver/gui/parameters.py @@ -1,7 +1,8 @@ import logging import math import numbers -from typing import Any, Optional, List, Callable +from typing import Any, Optional, List +import re from qcodes import Parameter @@ -15,6 +16,20 @@ # TODO: do all styling with a global style sheet +FLOAT_PRECISION = 10 # The maximum number of significant digits for float numbers + +def float_formater(val): + """ + For displaying float numbers with scientific notation. + """ + if isinstance(val, float): + if abs(val) > 1e5 or (0 < abs(val) < 1e-4): + formatted = f"{val:.{FLOAT_PRECISION - 1}g}" + # remove leading 0 in exponent + formatted = re.sub(r"e([+-])0(\d+)", r"e\1\2", formatted) + return formatted + return str(val) + class ParameterWidget(QtWidgets.QWidget): """A widget that allows editing and/or displaying a parameter value.""" @@ -35,7 +50,7 @@ class ParameterWidget(QtWidgets.QWidget): _valueFromWidget = QtCore.Signal(object) def __init__(self, parameter: Parameter, parent=None, - additionalWidgets: Optional[List[QtWidgets.QWidget]] = []): + additionalWidgets: Optional[List[QtWidgets.QWidget]] = None): super().__init__(parent) @@ -128,6 +143,10 @@ def __init__(self, parameter: Parameter, parent=None, self.paramWidget = QtWidgets.QLabel(self) self._setMethod = lambda x: self.paramWidget.setText(str(x)) \ if isinstance(self.paramWidget, QtWidgets.QLabel) else None + try: # also do immediate update for read-only params, as what we do for the editable parameters above. + self._setMethod(parameter()) + except Exception as e: + logger.warning(f"Error when setting parameter {parameter}: {e}", exc_info=True) layout.addWidget(self.paramWidget, 0, 0) additionalWidgets = additionalWidgets or [] @@ -217,7 +236,7 @@ def value(self): def setValue(self, val: Any): try: - self.input.setText(str(val)) + self.input.setText(float_formater(val)) except RuntimeError as e: logger.debug(f"Could not set value {val} in AnyInput element does not exists, raised {type(e)}: {e.args}") @@ -260,7 +279,7 @@ def value(self): def setValue(self, value: numbers.Number): try: - self.setText(str(value)) + self.setText(float_formater(value)) except RuntimeError as e: logger.debug(f"Could not set value {value} in NumberInput, raised {type(e)}: {e.args}") diff --git a/instrumentserver/log.py b/instrumentserver/log.py index d121f4c..cc5230e 100644 --- a/instrumentserver/log.py +++ b/instrumentserver/log.py @@ -5,8 +5,10 @@ import sys import logging from enum import Enum, auto, unique +from html import escape +import re -from . import QtGui, QtWidgets +from . import QtGui, QtWidgets, QtCore @unique @@ -17,7 +19,7 @@ class LogLevels(Enum): debug = auto() -class QLogHandler(logging.Handler): +class QLogHandler(QtCore.QObject,logging.Handler): """A simple log handler that supports logging in TextEdit""" COLORS = { @@ -26,21 +28,71 @@ class QLogHandler(logging.Handler): logging.INFO: QtGui.QColor('green'), logging.DEBUG: QtGui.QColor('gray'), } + + new_html = QtCore.Signal(str) def __init__(self, parent): - super().__init__() + QtCore.QObject.__init__(self, parent) + logging.Handler.__init__(self) + self.widget = QtWidgets.QTextEdit(parent) self.widget.setReadOnly(True) - - def emit(self, record): - msg = self.format(record) - clr = self.COLORS.get(record.levelno, QtGui.QColor('black')) - self.widget.setTextColor(clr) - self.widget.append(msg) + self._transform = None + + # connect signal to slot that actually touches the widget (GUI thread) + self.new_html.connect(self._append_html) + + + @QtCore.Slot(str) + def _append_html(self, html: str): + """Append HTML to the text widget in the GUI thread.""" + self.widget.append(html) + # reset char format so bold/italics don’t bleed into the next line + self.widget.setCurrentCharFormat(QtGui.QTextCharFormat()) + # keep view scrolled to bottom self.widget.verticalScrollBar().setValue( self.widget.verticalScrollBar().maximum() ) + + def set_transform(self, fn): + """fn(record, msg) -> str | {'html': str} | None""" + self._transform = fn + + def emit(self, record): + formatted = self.format(record) # prefix + message + raw_msg = record.getMessage() # message only + + # Color for prefix (log level) + clr = self.COLORS.get(record.levelno, QtGui.QColor('black')).name() + + if self._transform is not None: + html_fragment = self._transform(record, raw_msg) + if html_fragment: + i = formatted.rfind(raw_msg) + if i >= 0: + prefix = formatted[:i] + suffix = formatted[i + len(raw_msg):] + else: + prefix, suffix = "", "" + + # Build HTML line + html = ( + f"{escape(prefix)}" + f"{html_fragment}" + f"{escape(suffix)}" + ) + + # send to GUI thread + self.new_html.emit(html) + return + + # fallback: original plain text path + msg = formatted + clr_q = self.COLORS.get(record.levelno, QtGui.QColor('black')).name() + html = f"{escape(msg)}" + + self.new_html.emit(html) class LogWidget(QtWidgets.QWidget): """ @@ -58,6 +110,7 @@ def __init__(self, parent=None, level=logging.INFO): logTextBox = QLogHandler(self) logTextBox.setFormatter(fmt) logTextBox.setLevel(level) + self.handler = logTextBox # make the widget layout = QtWidgets.QVBoxLayout() @@ -77,6 +130,27 @@ def __init__(self, parent=None, level=logging.INFO): # del h self.logger.addHandler(logTextBox) + self.handler.set_transform(_param_update_formatter) + + +def _param_update_formatter(record, raw_msg): + """ + A formater that makes parameter updates more prominent in the gui log window. + """ + # Pattern 1: "parameter-update" from the broadcaster, for client station + pattern_update = re.compile(r'parameter-update:\s*([A-Za-z0-9_.]+):\s*(.+)', re.S) + + # Pattern 2: normal log message from the server. i.e. `Parameter {name} set to: {value}` + pattern_info = re.compile(r"Parameter\s+'([A-Za-z0-9_.]+)'\s+set\s+to:\s*(.+)", re.S) + + match = pattern_update.search(raw_msg) or pattern_info.search(raw_msg) + if not match: + return None + + name, value = match.groups() + + # Escape HTML but keep \n literal (QTextEdit.append will render them) + return ( f"{escape(name)} set to: " f"{escape(value)}" ) def setupLogging(addStreamHandler=True, logFile=None, diff --git a/instrumentserver/resource/icons/server_app_icon.svg b/instrumentserver/resource/icons/server_app_icon.svg new file mode 100644 index 0000000..6735e57 --- /dev/null +++ b/instrumentserver/resource/icons/server_app_icon.svg @@ -0,0 +1,319 @@ + + diff --git a/instrumentserver/serialize.py b/instrumentserver/serialize.py index 0c515e3..069ca72 100644 --- a/instrumentserver/serialize.py +++ b/instrumentserver/serialize.py @@ -285,7 +285,7 @@ def _singleInstrumentParametersToJson(instrument: InstrumentBase, else: snap = instrument.snapshot(update=get) for name, param in instrument.parameters.items(): - if name not in excludeParameters: + if (name not in excludeParameters) and (not param.snapshot_exclude): if len(includeMeta) == 0 and simpleFormat: ret[addPrefix + name] = snap['parameters'][name].get('value', None) else: diff --git a/instrumentserver/server/application.py b/instrumentserver/server/application.py index e4ff90f..b201ded 100644 --- a/instrumentserver/server/application.py +++ b/instrumentserver/server/application.py @@ -3,6 +3,7 @@ import logging import os import time +import sys from typing import Union, Optional, Any, Dict from instrumentserver.client import QtClient @@ -12,7 +13,7 @@ StationServer, InstrumentModuleBluePrint, ParameterBluePrint ) -from .. import QtCore, QtWidgets, QtGui, Client +from .. import QtCore, QtWidgets, QtGui, Client, getInstrumentserverPath from ..gui.misc import DetachableTabWidget, BaseDialog from ..gui.parameters import AnyInputForMethod from ..gui.instruments import GenericInstrument @@ -508,6 +509,11 @@ def __init__(self, startServer: Optional[bool] = True, self.instrumentTabsOpen: dict[str, GenericInstrument] = {} self.setWindowTitle('Instrument server') + # Set unique Windows App ID so that this app can have separate taskbar entry than other Qt apps + if sys.platform == "win32": + import ctypes + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("InstrumentServer.Server") + self.setWindowIcon(QtGui.QIcon(getInstrumentserverPath("resource", "icons") + "/server_app_icon.svg")) # A test client, just a simple helper object. self.client = EmbeddedClient(raise_exceptions=False, timeout=5000) @@ -727,6 +733,7 @@ def addInstrumentTab(self, item: QtWidgets.QTreeWidgetItem, index: int): if 'kwargs' in self._guiConfig[name]['gui']: kwargs = self._guiConfig[name]['gui']['kwargs'] + kwargs["sub_port"] = kwargs.get("sub_port", self.stationServer.port + 1) insWidget = widgetClass(ins, parent=self, **kwargs) index = self.tabs.addTab(insWidget, ins.name) self.instrumentTabsOpen[ins.name] = insWidget diff --git a/instrumentserver/testing/dummy_instruments/generic.py b/instrumentserver/testing/dummy_instruments/generic.py index 5147323..1a64398 100644 --- a/instrumentserver/testing/dummy_instruments/generic.py +++ b/instrumentserver/testing/dummy_instruments/generic.py @@ -94,12 +94,18 @@ def __init__(self, name: str, *args, **kwargs): self.random = np.random.randint(10000) self._param1 = 1 self._param2 = 2 + self._p1_get_counter = 0 self.add_parameter('random_int', get_cmd=self.get_random) - self.add_parameter('param1', get_cmd=lambda : self._param1, set_cmd=lambda p: setattr(self, '_param1', p)) + self.add_parameter('param1', get_cmd= self._get_param1, set_cmd=lambda p: setattr(self, '_param1', p)) self.add_parameter('param2', get_cmd=lambda : self._param2, set_cmd=lambda p: setattr(self, '_param2', p)) - + def _get_param1(self): + # for testing potentially redundant/duplicate get calls + print(f"-------------- getting {self.name}.param1, count {self._p1_get_counter}----------------") + self._p1_get_counter += 1 + return self._param1 + def get_random(self): return self.random From e8750815fda142db64e3063e005483ea0950ee2f Mon Sep 17 00:00:00 2001 From: Marcos Frenkel <38033108+marcosfrenkel@users.noreply.github.com> Date: Fri, 21 Nov 2025 16:13:28 -0600 Subject: [PATCH 2/2] Removed commented line from code --- instrumentserver/gui/instruments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentserver/gui/instruments.py b/instrumentserver/gui/instruments.py index 561d2ff..0d5ccf3 100644 --- a/instrumentserver/gui/instruments.py +++ b/instrumentserver/gui/instruments.py @@ -683,7 +683,7 @@ def __init__(self, ins: Union[ProxyInstrument, Instrument], parent=None, **model self._layout.addWidget(self.splitter) self.splitter.addWidget(self.parametersList) self.splitter.addWidget(self.methodsList) - # self.parametersList.model.refreshAll() # Chao: removed as we will call that later in the constructor of the param widget + # Resize param name, unit, and function name columns after entries loaded self.parametersList.view.resizeColumnToContents(0)