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..0d5ccf3 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)
+
+
+ # 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