diff --git a/index.html b/index.html index eddd9ab54..4a586ad66 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,9 @@
- + -Go to the default documentation.
+Go to the default documentation.
\ No newline at end of file diff --git a/v4.0.0/.buildinfo b/v4.0.0/.buildinfo new file mode 100644 index 000000000..8ea8b1e01 --- /dev/null +++ b/v4.0.0/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: fe1f4bada1379b44671b00d43a768631 +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/v4.0.0/.nojekyll b/v4.0.0/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/v4.0.0/_images/device_display.gif b/v4.0.0/_images/device_display.gif new file mode 100644 index 000000000..67a85402f Binary files /dev/null and b/v4.0.0/_images/device_display.gif differ diff --git a/v4.0.0/_images/expanded.jpg b/v4.0.0/_images/expanded.jpg new file mode 100644 index 000000000..ac07e6940 Binary files /dev/null and b/v4.0.0/_images/expanded.jpg differ diff --git a/v4.0.0/_images/function.png b/v4.0.0/_images/function.png new file mode 100644 index 000000000..924ef3c87 Binary files /dev/null and b/v4.0.0/_images/function.png differ diff --git a/v4.0.0/_images/kind_panel.gif b/v4.0.0/_images/kind_panel.gif new file mode 100644 index 000000000..9aff7dbbe Binary files /dev/null and b/v4.0.0/_images/kind_panel.gif differ diff --git a/v4.0.0/_modules/index.html b/v4.0.0/_modules/index.html new file mode 100644 index 000000000..1adfbce27 --- /dev/null +++ b/v4.0.0/_modules/index.html @@ -0,0 +1,140 @@ + + + + + +
+"""
+Module to define alarm summary frameworks and widgets.
+"""
+import enum
+import logging
+import os
+from collections import defaultdict
+from dataclasses import dataclass
+from functools import partial
+
+from ophyd.device import Kind
+from ophyd.signal import EpicsSignalBase
+from pydm.widgets.base import PyDMPrimitiveWidget
+from pydm.widgets.channel import PyDMChannel
+from pydm.widgets.drawing import (PyDMDrawing, PyDMDrawingCircle,
+ PyDMDrawingEllipse, PyDMDrawingPolygon,
+ PyDMDrawingRectangle, PyDMDrawingTriangle)
+from qtpy import QtCore, QtGui, QtWidgets
+from qtpy.QtCore import Qt
+
+from .plugins import register_signal
+from .utils import (TyphosObject, channel_from_signal,
+ get_all_signals_from_device, pyqt_class_from_enum)
+from .widgets import HappiChannel
+
+logger = logging.getLogger(__name__)
+
+
+class KindLevel(enum.IntEnum):
+ """Options for TyphosAlarm.kindLevel."""
+ HINTED = 0
+ NORMAL = 1
+ CONFIG = 2
+ OMITTED = 3
+
+
+class AlarmLevel(enum.IntEnum):
+ """
+ Possible summary alarm states for a device.
+
+ These are also the valuess emitted from TyphosAlarm.alarm_changed.
+ """
+ NO_ALARM = 0
+ MINOR = 1
+ MAJOR = 2
+ INVALID = 3
+ DISCONNECTED = 4
+
+
+_KindLevel = pyqt_class_from_enum(KindLevel)
+_AlarmLevel = pyqt_class_from_enum(AlarmLevel)
+
+
+# Define behavior for the user's Kind selection.
+KIND_FILTERS = {
+ KindLevel.HINTED:
+ (lambda walk: walk.item.kind == Kind.hinted),
+ KindLevel.NORMAL:
+ (lambda walk: walk.item.kind in (Kind.hinted, Kind.normal)),
+ KindLevel.CONFIG:
+ (lambda walk: walk.item.kind != Kind.omitted),
+ KindLevel.OMITTED:
+ (lambda walk: True),
+}
+
+
+@dataclass
+class SignalInfo:
+ address: str
+ channel: PyDMChannel
+ signal_name: str
+ connected: bool
+ severity: int
+
+ @property
+ def alarm(self) -> AlarmLevel:
+ if not self.connected:
+ return AlarmLevel.DISCONNECTED
+ else:
+ return AlarmLevel(self.severity)
+
+ def describe(self) -> str:
+ alarm = self.alarm
+ if alarm == AlarmLevel.NO_ALARM:
+ desc = f'{self.address} has no alarm'
+ elif alarm in (AlarmLevel.DISCONNECTED, AlarmLevel.INVALID):
+ desc = f'{self.address} is {alarm.name}'
+ else:
+ desc = f'{self.address} has a {alarm.name} alarm'
+ if self.signal_name:
+ return f'{desc} ({self.signal_name})'
+ else:
+ return desc
+
+
+class TyphosAlarm(TyphosObject, PyDMDrawing, _KindLevel, _AlarmLevel):
+ """
+ Class that holds logic and routines common to all Typhos Alarm widgets.
+
+ Overall, these classes exist to summarize alarm states from Ophyd Devices
+ and change the colors on indicator widgets appropriately.
+
+ We will consider a subset of the signals that is of KindLevel and above and
+ summarize state based on the "worst" alarm we see as defined by AlarmLevel.
+ """
+ QtCore.Q_ENUMS(_KindLevel)
+ QtCore.Q_ENUMS(_AlarmLevel)
+
+ KindLevel = KindLevel
+ AlarmLevel = AlarmLevel
+
+ _qt_designer_ = {
+ "group": "Typhos Alarm Widgets",
+ "is_container": False,
+ }
+
+ alarm_changed = QtCore.Signal(_AlarmLevel)
+
+ def __init__(self, *args, **kwargs):
+ self._kind_level = KindLevel.HINTED
+ super().__init__(*args, **kwargs)
+ # Default drawing properties, can override if needed
+ self.penWidth = 2
+ self.penColor = QtGui.QColor('black')
+ self.penStyle = Qt.SolidLine
+ self.reset_alarm_state()
+ self.alarm_changed.connect(self.set_alarm_color)
+
+ @QtCore.Property(_KindLevel)
+ def kindLevel(self):
+ """
+ Determines which signals to include in the alarm summary.
+
+ If this is "hinted", only include hinted signals.
+ If this is "normal", include normal and hinted signals.
+ If this is "config", include everything except for omitted signals
+ If this is "omitted", include all signals
+ """
+ return self._kind_level
+
+ @kindLevel.setter
+ def kindLevel(self, kind_level):
+ # We must update the alarm config to add/remove PVs as appropriate.
+ self._kind_level = kind_level
+ self.update_alarm_config()
+
+ @QtCore.Property(str)
+ def channel(self):
+ """
+ The channel address to use for this widget.
+
+ If this is a happi:// channel, we'll create the device and
+ add it to this widget.
+
+ If this is a ca:// channel, we'll connect to the PV and include its
+ alarm information in the evaluation of this widget.
+
+ There is an assumption that you'll either be using this via one of the
+ channel options or by using "add_device" one or more times. There may
+ be some strange behavior if you try to set up this widget using both
+ approaches at the same time.
+ """
+ if self._channel:
+ return str(self._channel)
+ return None
+
+ @channel.setter
+ def channel(self, value):
+ if self._channel != value:
+ # Remove old connection
+ if self._channels:
+ for channel in self._channels:
+ if hasattr(channel, 'disconnect'):
+ channel.disconnect()
+ if channel in self.signal_info:
+ del self.signal_info[channel]
+ self._channels.clear()
+ # Load new channel
+ self._channel = str(value)
+ if 'happi://' in self._channel:
+ channel = HappiChannel(
+ address=self._channel,
+ tx_slot=self._tx,
+ )
+ else:
+ channel = PyDMChannel(
+ address=self._channel,
+ connection_slot=partial(self.update_connection,
+ addr=self._channel),
+ severity_slot=partial(self.update_severity,
+ addr=self._channel),
+ )
+ self.signal_info[self._channel] = SignalInfo(
+ address=self._channel,
+ channel=channel,
+ signal_name='',
+ connected=False,
+ severity=AlarmLevel.INVALID,
+ )
+ self._channels = [channel]
+ # Connect the channel to the HappiPlugin
+ if hasattr(channel, 'connect'):
+ channel.connect()
+
+ def _tx(self, value):
+ """Receive information from happi channel"""
+ self.add_device(value['obj'])
+
+ def reset_alarm_state(self):
+ self.signal_info = {}
+ self.device_info = defaultdict(list)
+ self.alarm_summary = AlarmLevel.DISCONNECTED
+ self.set_alarm_color(AlarmLevel.DISCONNECTED)
+
+ def channels(self):
+ """
+ Let pydm know about our pydm channels.
+ """
+ ch = list(self._channels)
+ for info in self.signal_info.values():
+ ch.append(info.channel)
+ return ch
+
+ def add_device(self, device):
+ """
+ Initialize our alarm handling when adding a device.
+ """
+ super().add_device(device)
+ self.setup_alarm_config(device)
+
+ def clear_all_alarm_configs(self):
+ """
+ Reset this widget down to the "no alarm handling" state.
+ """
+ for ch in (info.channel for info in self.signal_info.values()):
+ ch.disconnect()
+ self.reset_alarm_state()
+
+ def setup_alarm_config(self, device):
+ """
+ Add a device to the alarm summary.
+
+ This will pick PVs based on the device kind and the configured kind
+ level, configuring the PyDMChannels to update our alarm state and
+ color when we get updates from our PVs.
+ """
+ sigs = get_all_signals_from_device(
+ device,
+ filter_by=KIND_FILTERS[self._kind_level]
+ )
+ channel_addrs = [channel_from_signal(sig) for sig in sigs]
+ for sig in sigs:
+ if not isinstance(sig, EpicsSignalBase):
+ register_signal(sig)
+ channels = [
+ PyDMChannel(
+ address=addr,
+ connection_slot=partial(self.update_connection, addr=addr),
+ severity_slot=partial(self.update_severity, addr=addr),
+ )
+ for addr in channel_addrs]
+
+ for ch, sig in zip(channels, sigs):
+ info = SignalInfo(
+ address=ch.address,
+ channel=ch,
+ signal_name=sig.dotted_name,
+ connected=False,
+ severity=AlarmLevel.INVALID,
+ )
+ self.signal_info[ch.address] = info
+ self.device_info[device.name].append(info)
+ ch.connect()
+
+ all_channels = self.channels()
+ if all_channels:
+ logger.debug(
+ f'Finished setup of alarm config for device {device.name} on '
+ f'alarm widget with channel {all_channels[0]}.'
+ )
+ else:
+ logger.warning(
+ f'Tried to set up alarm config for device {device.name}, but '
+ 'did not configure any channels! Check your kindLevel!'
+ )
+
+ def update_alarm_config(self):
+ """
+ Clean up the existing alarm config and create a new one.
+
+ This must be called when settings like KindLevel are changed so we can
+ re-evaluate them.
+ """
+ self.clear_all_alarm_configs()
+ for dev in self.devices:
+ self.setup_alarm_config(dev)
+
+ def update_connection(self, connected, addr):
+ """Slot that will be called when a PV connects or disconnects."""
+ self.signal_info[addr].connected = connected
+ self.update_current_alarm()
+
+ def update_severity(self, severity, addr):
+ """Slot that will be called when a PV's alarm severity changes."""
+ self.signal_info[addr].severity = severity
+ self.update_current_alarm()
+
+ def update_current_alarm(self):
+ """
+ Check what the current worst available alarm state is.
+
+ If the alarm state is different than the last time we checked,
+ emit the "alarm_changed" signal. This signal is configured at
+ init to change the color of this widget.
+ """
+ if not self.signal_info:
+ new_alarm = AlarmLevel.INVALID
+ else:
+ new_alarm = max(info.alarm for info in self.signal_info.values())
+ if new_alarm != self.alarm_summary:
+ try:
+ self.alarm_changed.emit(new_alarm)
+ except RuntimeError:
+ # Widget was destroyed and not properly cleaned up
+ logger.debug('Dangling reference to alarm widget!')
+ return
+ else:
+ logger.debug(
+ f'Updated alarm from {self.alarm_summary} to {new_alarm} '
+ f'on alarm widget with channel {self.channels()[0]}'
+ )
+ self.alarm_summary = new_alarm
+
+ def set_alarm_color(self, alarm_level):
+ """
+ Change the alarm color to the shade defined by the current alarm level.
+ """
+ self.setStyleSheet(indicator_stylesheet(self.__class__, alarm_level))
+
+ def eventFilter(self, obj, event):
+ """
+ Extra handling for showing the user which alarms are alarming.
+
+ We'll show this information on mouseover if anything is disconnected or
+ in an alarm state, unless the user middle-clicks, which will have the
+ default PyDM behavior of showing all the channels and copying them to
+ clipboard.
+ """
+ # super() doesn't work here, some strange pyqt thing
+ default_pydm_event = PyDMPrimitiveWidget.eventFilter(self, obj, event)
+ if default_pydm_event:
+ return True
+ if event.type() == QtCore.QEvent.Enter:
+ alarming = self.show_alarm_tooltip(event)
+ return alarming
+ return False
+
+ def show_alarm_tooltip(self, event):
+ """
+ Show a tooltip that reveals which channels are alarmed or disconnected.
+ """
+ tooltip_lines = []
+
+ # Start with the channel field, just show the status.
+ if self.channel in self.signal_info:
+ info = self.signal_info[self.channel]
+ tooltip_lines.append(f'Channel {info.describe()}')
+
+ # Handle each device
+ for name, device_info_list in self.device_info.items():
+ # At least show the device name
+ tooltip_lines.append(f'Device {name}')
+ has_alarm = False
+ for info in device_info_list:
+ if info.alarm != AlarmLevel.NO_ALARM:
+ if not has_alarm:
+ has_alarm = True
+ tooltip_lines.append('-' * 2 * len(tooltip_lines[-1]))
+ tooltip_lines.append(info.describe())
+
+ if tooltip_lines:
+ tooltip = os.linesep.join(tooltip_lines)
+ QtWidgets.QToolTip.showText(
+ self.mapToGlobal(
+ QtCore.QPoint(event.x() + 10, event.y())),
+ tooltip,
+ self,
+ )
+
+ # Return True if we showed something
+ return bool(tooltip_lines)
+
+
+# Subclass an re-introduce properties as needed
+# Each of these must be included for these to work in designer
+
+class TyphosAlarmCircle(TyphosAlarm, PyDMDrawingCircle):
+ QtCore.Q_ENUMS(_KindLevel)
+ kindLevel = TyphosAlarm.kindLevel
+
+
+class TyphosAlarmRectangle(TyphosAlarm, PyDMDrawingRectangle):
+ QtCore.Q_ENUMS(_KindLevel)
+ kindLevel = TyphosAlarm.kindLevel
+
+
+class TyphosAlarmTriangle(TyphosAlarm, PyDMDrawingTriangle):
+ QtCore.Q_ENUMS(_KindLevel)
+ kindLevel = TyphosAlarm.kindLevel
+
+
+class TyphosAlarmEllipse(TyphosAlarm, PyDMDrawingEllipse):
+ QtCore.Q_ENUMS(_KindLevel)
+ kindLevel = TyphosAlarm.kindLevel
+
+
+class TyphosAlarmPolygon(TyphosAlarm, PyDMDrawingPolygon):
+ QtCore.Q_ENUMS(_KindLevel)
+ kindLevel = TyphosAlarm.kindLevel
+ numberOfPoints = PyDMDrawingPolygon.numberOfPoints
+
+
+def indicator_stylesheet(shape_cls, alarm):
+ """
+ Create the indicator stylesheet that will modify a PyDMDrawing's color.
+
+ Parameters
+ ----------
+ shape_cls : type
+ The PyDMDrawing widget subclass.
+
+ alarm : int
+ The value from AlarmLevel
+
+ Returns
+ -------
+ indicator_stylesheet : str
+ The correctly colored stylesheet to apply to the widget.
+ """
+ base = (
+ f'{shape_cls.__name__} '
+ '{border: none; '
+ ' background: transparent;'
+ ' qproperty-brush: rgba'
+ )
+
+ if alarm == AlarmLevel.DISCONNECTED:
+ return base + '(255,255,255,255);}'
+ elif alarm == AlarmLevel.NO_ALARM:
+ return base + '(0,255,0,255);}'
+ elif alarm == AlarmLevel.MINOR:
+ return base + '(255,255,0,255);}'
+ elif alarm == AlarmLevel.MAJOR:
+ return base + '(255,0,0,255);}'
+ elif alarm == AlarmLevel.INVALID:
+ return base + '(255,0,255,255);}'
+ else:
+ raise ValueError(f'Recieved invalid alarm level {alarm}')
+
+import fnmatch
+import functools
+import logging
+import os
+import pathlib
+import re
+import time
+
+from qtpy import QtCore
+
+from . import utils
+from .widgets import SignalWidgetInfo
+
+logger = logging.getLogger(__name__)
+
+
+# Global cache state. Do not use these directly, but instead use
+# `get_global_describe_cache()` and `get_global_widget_type_cache()` below.
+_GLOBAL_WIDGET_TYPE_CACHE = None
+_GLOBAL_DESCRIBE_CACHE = None
+_GLOBAL_DISPLAY_PATH_CACHE = None
+
+
+
+[docs]
+def get_global_describe_cache():
+ """Get the _GlobalDescribeCache singleton."""
+ global _GLOBAL_DESCRIBE_CACHE
+ if _GLOBAL_DESCRIBE_CACHE is None:
+ _GLOBAL_DESCRIBE_CACHE = _GlobalDescribeCache()
+ return _GLOBAL_DESCRIBE_CACHE
+
+
+
+
+[docs]
+def get_global_widget_type_cache():
+ """Get the _GlobalWidgetTypeCache singleton."""
+ global _GLOBAL_WIDGET_TYPE_CACHE
+ if _GLOBAL_WIDGET_TYPE_CACHE is None:
+ _GLOBAL_WIDGET_TYPE_CACHE = _GlobalWidgetTypeCache()
+ return _GLOBAL_WIDGET_TYPE_CACHE
+
+
+
+
+[docs]
+def get_global_display_path_cache():
+ """Get the _GlobalDisplayPathCache singleton."""
+ global _GLOBAL_DISPLAY_PATH_CACHE
+ if _GLOBAL_DISPLAY_PATH_CACHE is None:
+ _GLOBAL_DISPLAY_PATH_CACHE = _GlobalDisplayPathCache()
+ return _GLOBAL_DISPLAY_PATH_CACHE
+
+
+
+
+[docs]
+class _GlobalDescribeCache(QtCore.QObject):
+ """
+ Cache of ophyd object descriptions.
+
+ ``obj.describe()`` is called in a thread from the global QThreadPool, and
+ new results are marked by the Signal ``new_description``.
+
+ To access a description, call :meth:`.get`. If available, it will be
+ returned immediately. Otherwise, wait for the ``new_description`` Signal.
+
+ Attributes
+ ----------
+ connect_thread : :class:`ObjectConnectionMonitorThread`
+ The thread which monitors connection status.
+
+ cache : dict
+ The cache holding descriptions, keyed on ``obj``.
+ """
+
+ new_description = QtCore.Signal(object, dict)
+
+ def __init__(self):
+ super().__init__()
+ self._in_process = set()
+ self.cache = {}
+
+ self.connect_thread = utils.ObjectConnectionMonitorThread(parent=self)
+ self.connect_thread.connection_update.connect(
+ self._connection_update, QtCore.Qt.QueuedConnection)
+
+ self.connect_thread.start()
+
+
+[docs]
+ def clear(self):
+ """Clear the cache."""
+ self.connect_thread.clear()
+ self.cache.clear()
+ self._in_process.clear()
+
+
+ def _describe(self, obj):
+ """Retrieve the description of ``obj``."""
+ try:
+ return obj.describe()[obj.name]
+ except Exception:
+ logger.error("Unable to connect to %r during widget creation",
+ obj.name)
+ logger.debug("Unable to connect to %r during widget creation",
+ obj.name, exc_info=True)
+ return {}
+
+ def _worker_describe(self, obj):
+ """
+ This is the worker thread method that gets run in the thread pool.
+
+ It calls describe, updates the cache, and emits a signal when done.
+ """
+ if obj not in self._in_process:
+ # Cache was cleared before the signal was needed. Discard.
+ return
+
+ try:
+ self.cache[obj] = desc = self._describe(obj)
+ if obj in self._in_process:
+ self.new_description.emit(obj, desc)
+ except Exception as ex:
+ logger.exception('Worker describe failed: %s', ex)
+ finally:
+ try:
+ self._in_process.remove(obj)
+ except KeyError:
+ # The cache can be cleared externally. Don't fail if the object
+ # is already gone.
+ ...
+
+ @QtCore.Slot(object, bool, dict)
+ def _connection_update(self, obj, connected, metadata):
+ """
+ A connection callback from the connection monitor thread.
+ """
+ if not connected:
+ return
+ elif self.cache.get(obj) or obj in self._in_process:
+ return
+
+ self._in_process.add(obj)
+ func = functools.partial(self._worker_describe, obj)
+ QtCore.QThreadPool.globalInstance().start(
+ utils.ThreadPoolWorker(func)
+ )
+
+
+[docs]
+ def get(self, obj):
+ """
+ To access a description, call this method. If available, it will be
+ returned immediately. Otherwise, upon connection and successful
+ ``describe()`` call, the ``new_description`` Signal will be emitted.
+
+ Parameters
+ ----------
+ obj : :class:`ophyd.OphydObj`
+ The object to get the description of.
+
+ Returns
+ -------
+ desc : dict or None
+ If available in the cache, the description will be returned.
+ """
+ try:
+ return self.cache[obj]
+ except KeyError:
+ # Add the object, waiting for a connection update to determine
+ # widget types
+ self.connect_thread.add_object(obj)
+
+
+
+
+
+[docs]
+class _GlobalWidgetTypeCache(QtCore.QObject):
+ """
+ Cache of ophyd object Typhos widget types.
+
+ ``obj.describe()`` is called using :class:`_GlobalDescribeCache` and are
+ therefore threaded and run in the background. New results are marked by
+ the Signal ``widgets_determined``.
+
+ To access a set of widget types, call :meth:`.get`. If available, it will
+ be returned immediately. Otherwise, wait for the ``widgets_determined``
+ Signal.
+
+ Attributes
+ ----------
+ describe_cache : :class:`_GlobalDescribeCache`
+ The describe cache, used for determining widget types.
+
+ cache : dict
+ The cache holding widget type information.
+ Keyed on ``obj``, the values are :class:`SignalWidgetInfo` tuples.
+ """
+
+ widgets_determined = QtCore.Signal(object, SignalWidgetInfo)
+
+ def __init__(self):
+ super().__init__()
+ self.cache = {}
+ self.describe_cache = get_global_describe_cache()
+ self.describe_cache.new_description.connect(self._new_description,
+ QtCore.Qt.QueuedConnection)
+
+
+
+
+ @QtCore.Slot(object, dict)
+ def _new_description(self, obj, desc):
+ """New description: determine widget types and update the cache."""
+ if not desc:
+ # Marks an error in retrieving the description
+ # TODO: show error widget or some default widget?
+ return
+
+ item = SignalWidgetInfo.from_signal(obj, desc)
+ logger.debug('Determined widgets for %s: %s', obj.name, item)
+ self.cache[obj] = item
+ self.widgets_determined.emit(obj, item)
+
+
+[docs]
+ def get(self, obj):
+ """
+ To access widget types, call this method. If available, it will be
+ returned immediately. Otherwise, upon connection and successful
+ ``describe()`` call, the ``widgets_determined`` Signal will be emitted.
+
+ Parameters
+ ----------
+ obj : :class:`ophyd.OphydObj`
+ The object to get the widget types.
+
+ Returns
+ -------
+ desc : :class:`SignalWidgetInfo` or None
+ If available in the cache, the information will be returned.
+ """
+ try:
+ return self.cache[obj]
+ except KeyError:
+ # Add the signal, waiting for a connection update to determine
+ # widget types
+ desc = self.describe_cache.get(obj)
+ if desc is not None:
+ # In certain scenarios (such as testing) this might happen
+ self._new_description(obj, desc)
+
+
+
+
+# The default stale cached_path threshold time, in seconds:
+TYPHOS_DISPLAY_PATH_CACHE_TIME = int(
+ os.environ.get('TYPHOS_DISPLAY_PATH_CACHE_TIME', '600')
+)
+
+
+class _CachedPath:
+ """
+ A wrapper around pathlib.Path to support repeated globbing.
+
+ Parameters
+ ----------
+ path : pathlib.Path
+ The path to cache.
+
+ Attributes
+ ----------
+ path : pathlib.Path
+ The underlying path.
+ cache : list
+ The cache of filenames.
+ _update_time : float
+ The last time the cache was updated.
+ stale_threshold : float, optional
+ The time (in seconds) after which to update the path cache. This
+ happens on the next glob, and not on a timer-basis.
+ """
+
+ def __init__(self, path, *,
+ stale_threshold=TYPHOS_DISPLAY_PATH_CACHE_TIME):
+ self.path = pathlib.Path(path)
+ self.cache = None
+ self._update_time = None
+ self.stale_threshold = stale_threshold
+
+ @classmethod
+ def from_path(cls, path, **kwargs):
+ """
+ Get a cached path, if not already cached.
+
+ Parameters
+ ----------
+ path : :class:`pathlib.Path` or :class:`_CachedPath`
+ The paths to cache, if not already cached.
+ """
+ if isinstance(path, (cls, _GlobalDisplayPathCache)):
+ # Already a _CachedPath
+ return path
+ return cls(path, **kwargs)
+
+ def __hash__(self):
+ # Keep the hash the same as the internal path for set()/dict() usage
+ return hash(self.path)
+
+ @property
+ def time_since_last_update(self):
+ """Time (in seconds) since the last update."""
+ if self._update_time is None:
+ return 0
+ return time.monotonic() - self._update_time
+
+ def update(self):
+ """Update the file list."""
+ self.cache = os.listdir(self.path)
+ self._update_time = time.monotonic()
+
+ def glob(self, pattern):
+ """Glob a pattern."""
+ if self.cache is None:
+ self.update()
+ elif self.time_since_last_update > self.stale_threshold:
+ self.update()
+
+ if any(c in pattern for c in '*?['):
+ # Convert from glob syntax -> regular expression
+ # And compile it for repeated usage.
+ regex = re.compile(fnmatch.translate(pattern))
+ for path in self.cache:
+ if regex.match(path):
+ yield self.path / path
+ else:
+ # No globbing syntax: only check if file is in the list
+ if pattern in self.cache:
+ yield self.path / pattern
+
+
+
+[docs]
+class _GlobalDisplayPathCache:
+ """
+ A cache for all configured display paths.
+
+ All paths from `utils.DISPLAY_PATHS` will be included:
+ 1. Environment variable ``PYDM_DISPLAYS_PATH``.
+ 2. Typhos package built-in paths.
+ """
+
+ def __init__(self):
+ self.paths = []
+ for path in utils.DISPLAY_PATHS:
+ self.add_path(path)
+
+
+[docs]
+ def update(self):
+ """Force a reload of all paths in the cache."""
+ logger.debug('Clearing global path cache.')
+ for path in self.paths:
+ path.cache = None
+
+
+
+[docs]
+ def add_path(self, path):
+ """
+ Add a path to be searched during ``glob``.
+
+ Parameters
+ ----------
+ path : pathlib.Path or str
+ The path to add.
+ """
+ logger.debug('Path added to _GlobalDisplayPathCache: %s', path)
+ path = pathlib.Path(path).expanduser().resolve()
+ path = _CachedPath(
+ path, stale_threshold=TYPHOS_DISPLAY_PATH_CACHE_TIME)
+ if path not in self.paths:
+ self.paths.append(path)
+
+
+
+"""Contains the main display widget used for representing an entire device."""
+from __future__ import annotations
+
+import copy
+import enum
+import inspect
+import logging
+import os
+import pathlib
+import webbrowser
+from typing import Dict, List, Optional, Union
+
+import ophyd
+import pcdsutils
+import pydm
+import pydm.display
+import pydm.exception
+import pydm.utilities
+from pcdsutils.qt import forward_property
+from qtpy import QtCore, QtGui, QtWidgets
+from qtpy.QtCore import Q_ENUMS, Property, Qt, Slot
+
+from . import cache
+from . import panel as typhos_panel
+from . import utils, web, widgets
+from .jira import TyphosJiraIssueWidget
+from .notes import TyphosNotesEdit
+from .plugins.core import register_signal
+
+logger = logging.getLogger(__name__)
+
+
+class DisplayTypes(enum.IntEnum):
+ """Enumeration of template types that can be used in displays."""
+
+ embedded_screen = 0
+ detailed_screen = 1
+ engineering_screen = 2
+
+ @property
+ def friendly_name(self) -> str:
+ """A user-friendly name for the display type."""
+ return {
+ self.embedded_screen: "Embedded",
+ self.detailed_screen: "Detailed",
+ self.engineering_screen: "Engineering",
+ }[self]
+
+
+_DisplayTypes = utils.pyqt_class_from_enum(DisplayTypes)
+DisplayTypes.names = [view.name for view in DisplayTypes]
+
+
+class ScrollOptions(enum.IntEnum):
+ """Enumeration of scrollable options for displays."""
+
+ auto = 0
+ scrollbar = 1
+ no_scroll = 2
+
+
+_ScrollOptions = utils.pyqt_class_from_enum(ScrollOptions)
+ScrollOptions.names = [view.name for view in ScrollOptions]
+
+
+DEFAULT_TEMPLATES = {
+ name: [(utils.ui_dir / 'core' / f'{name}.ui').resolve()]
+ for name in DisplayTypes.names
+}
+
+DETAILED_TREE_TEMPLATE = (utils.ui_dir / 'core' / 'detailed_tree.ui').resolve()
+DEFAULT_TEMPLATES['detailed_screen'].append(DETAILED_TREE_TEMPLATE)
+
+DEFAULT_TEMPLATES_FLATTEN = [f for _, files in DEFAULT_TEMPLATES.items()
+ for f in files]
+
+
+
+[docs]
+def normalize_display_type(
+ display_type: Union[DisplayTypes, str, int]
+) -> DisplayTypes:
+ """
+ Normalize a given display type.
+
+ Parameters
+ ----------
+ display_type : DisplayTypes, str, or int
+ The display type.
+
+ Returns
+ -------
+ display_type : DisplayTypes
+ The normalized :class:`DisplayTypes`.
+
+ Raises
+ ------
+ ValueError
+ If the input cannot be made a :class:`DisplayTypes`.
+ """
+ try:
+ return DisplayTypes(display_type)
+ except ValueError:
+ try:
+ return DisplayTypes[display_type]
+ except KeyError:
+ raise ValueError(
+ f'Unrecognized display type: {display_type}'
+ )
+
+
+
+def normalize_scroll_option(
+ scroll_option: Union[ScrollOptions, str, int]
+) -> ScrollOptions:
+ """
+ Normalize a given scroll option.
+
+ Parameters
+ ----------
+ display_type : ScrollOptions, str, or int
+ The display type.
+
+ Returns
+ -------
+ display_type : ScrollOptions
+ The normalized :class:`ScrollOptions`.
+
+ Raises
+ ------
+ ValueError
+ If the input cannot be made a :class:`ScrollOptions`.
+ """
+ try:
+ return ScrollOptions(scroll_option)
+ except ValueError:
+ try:
+ return ScrollOptions[scroll_option]
+ except KeyError:
+ raise ValueError(
+ f'Unrecognized scroll option: {scroll_option}'
+ )
+
+
+
+[docs]
+class TyphosToolButton(QtWidgets.QToolButton):
+ """
+ Base class for tool buttons used in the TyphosDisplaySwitcher.
+
+ Parameters
+ ----------
+ icon : QIcon or str, optional
+ See :meth:`.get_icon` for options.
+
+ parent : QtWidgets.QWidget, optional
+ The parent widget.
+
+ Attributes
+ ----------
+ DEFAULT_ICON : str
+ The default icon from fontawesome to use.
+ """
+
+ DEFAULT_ICON = 'circle'
+
+ def __init__(self, icon=None, *, parent=None):
+ super().__init__(parent=parent)
+
+ self.setContextMenuPolicy(Qt.DefaultContextMenu)
+ self.contextMenuEvent = self.open_context_menu
+ self.clicked.connect(self._clicked)
+ self.setIcon(self.get_icon(icon))
+ self.setMinimumSize(24, 24)
+
+ def _clicked(self):
+ """Clicked callback: override in a subclass."""
+ menu = self.generate_context_menu()
+ if menu:
+ menu.exec_(QtGui.QCursor.pos())
+
+
+
+
+
+[docs]
+ @classmethod
+ def get_icon(cls, icon=None):
+ """
+ Get a QIcon, if specified, or fall back to the default.
+
+ Parameters
+ ----------
+ icon : str or QtGui.QIcon
+ If a string, assume it is from fontawesome.
+ Otherwise, use the icon instance as-is.
+ """
+ icon = icon or cls.DEFAULT_ICON
+ if isinstance(icon, str):
+ return pydm.utilities.IconFont().icon(icon)
+ return icon
+
+
+
+
+
+
+
+
+[docs]
+class TyphosDisplayConfigButton(TyphosToolButton):
+ """
+ The configuration button used in the :class:`TyphosDisplaySwitcher`.
+
+ This uses the common "vertical ellipse" icon by default.
+ """
+
+ DEFAULT_ICON = 'ellipsis-v'
+
+ _kind_to_property = typhos_panel.TyphosSignalPanel._kind_to_property
+
+ def __init__(self, icon=None, *, parent=None):
+ super().__init__(icon=icon, parent=parent)
+ self.setPopupMode(self.InstantPopup)
+ self.setArrowType(Qt.NoArrow)
+ self.templates = None
+ self.device_display = None
+
+
+[docs]
+ def set_device_display(self, device_display):
+ """Typhos callback: set the :class:`TyphosDeviceDisplay`."""
+ self.device_display = device_display
+
+
+
+
+
+
+
+
+
+[docs]
+ def hide_empty(self, search=True):
+ """
+ Wrap hide_empty calls for use with search functions and action clicks.
+
+ Parameters
+ ----------
+ search : bool
+ Whether or not this method is being called from a search/filter
+ method.
+ """
+ if self.device_display.hideEmpty:
+ if search:
+ show_empty(self.device_display)
+ hide_empty(self.device_display, process_widget=False)
+
+
+
+
+
+
+
+
+
+
+
+[docs]
+class TyphosDisplaySwitcherButton(TyphosToolButton):
+ """A button which switches the TyphosDeviceDisplay template on click."""
+
+ templates: Optional[List[pathlib.Path]]
+ template_selected = QtCore.Signal(pathlib.Path)
+
+ icons = {'embedded_screen': 'compress',
+ 'detailed_screen': 'braille',
+ 'engineering_screen': 'cogs'
+ }
+
+ def __init__(self, display_type, *, parent=None):
+ super().__init__(icon=self.icons[display_type], parent=parent)
+ self.templates = None
+
+ def _clicked(self) -> None:
+ """Clicked callback - set the template."""
+ if self.templates is None:
+ logger.warning('set_device_display not called on %s', self)
+ return
+
+ # Show all our options in the context menu:
+ super()._clicked()
+
+
+
+
+
+
+
+[docs]
+class TyphosDisplaySwitcher(QtWidgets.QFrame, widgets.TyphosDesignerMixin):
+ """Display switcher set of buttons for use with a TyphosDeviceDisplay."""
+
+ help_toggle_button: TyphosHelpToggleButton
+ jira_report_button: Optional[TyphosJiraReportButton]
+ buttons: Dict[str, TyphosToolButton]
+ config_button: TyphosDisplayConfigButton
+ _jira_widget: TyphosJiraIssueWidget
+
+ template_selected = QtCore.Signal(pathlib.Path)
+
+ def __init__(self, parent=None, **kwargs):
+ # Intialize background variable
+ super().__init__(parent=None)
+
+ self.device_display = None
+ self.buttons = {}
+ layout = QtWidgets.QHBoxLayout()
+ self.setLayout(layout)
+ layout.setSpacing(0)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self.setContextMenuPolicy(Qt.DefaultContextMenu)
+ self.contextMenuEvent = self.open_context_menu
+
+ if parent:
+ self.setParent(parent)
+
+ self._create_ui()
+
+
+[docs]
+ def new_jira_widget(self):
+ """Open a new Jira issue reporting widget."""
+ if self.device_display is None:
+ logger.warning('set_device_display not called on %s', self)
+ return
+ devices = self.device_display.devices
+ device = devices[0] if devices else None
+ self._jira_widget = TyphosJiraIssueWidget(device=device)
+ self._jira_widget.show()
+
+
+ def _create_ui(self):
+ layout = self.layout()
+ self.buttons.clear()
+
+ self.help_toggle_button = TyphosHelpToggleButton()
+ layout.addWidget(self.help_toggle_button, 0, Qt.AlignRight)
+
+ if not utils.JIRA_URL:
+ self.jira_report_button = None
+ else:
+ self.jira_report_button = TyphosJiraReportButton()
+ self.jira_report_button.clicked.connect(self.new_jira_widget)
+ layout.addWidget(self.jira_report_button, 0, Qt.AlignRight)
+
+ self.config_button = TyphosDisplayConfigButton()
+ layout.addWidget(self.config_button, 0, Qt.AlignRight)
+ self.config_button.setToolTip('Display settings...')
+
+ def _template_selected(self, template):
+ """Template selected hook."""
+ self.template_selected.emit(template)
+ if self.device_display is not None:
+ self.device_display.force_template = template
+
+ def _templates_loaded(self, templates: Dict[str, List[pathlib.Path]]) -> None:
+ ...
+
+
+[docs]
+ def set_device_display(self, display: TyphosDeviceDisplay) -> None:
+ """Typhos hook for setting the associated device display."""
+ self.device_display = display
+ display.templates_loaded.connect(self._templates_loaded)
+ self._templates_loaded(display.templates)
+ self.config_button.set_device_display(display)
+
+
+
+[docs]
+ def add_device(self, device):
+ """Typhos hook for setting the associated device."""
+ ...
+
+
+
+
+
+[docs]
+class TyphosTitleLabel(QtWidgets.QLabel):
+ """
+ A label class intended for use as a standardized title.
+
+ Attributes
+ ----------
+ toggle_requested : QtCore.Signal
+ A Qt signal indicating that the user clicked on the title. By default,
+ this hides any nested panels underneath the title.
+ """
+
+ toggle_requested = QtCore.Signal()
+
+
+[docs]
+ def mousePressEvent(self, event):
+ """Overridden qt hook for a mouse press."""
+ if event.button() == Qt.LeftButton:
+ self.toggle_requested.emit()
+
+ super().mousePressEvent(event)
+
+
+
+
+class TyphosJiraReportButton(TyphosToolButton):
+ """A standard button for Jira reporting with typhos."""
+
+ def __init__(
+ self,
+ icon: str = "exclamation",
+ parent: Optional[QtWidgets.QWidget] = None,
+ ):
+ super().__init__(icon, parent=parent)
+
+ self.setToolTip("Report an issue about this device with Jira")
+
+
+class TyphosHelpToggleButton(TyphosToolButton):
+ """
+ A standard button used to toggle help information display.
+
+ Attributes
+ ----------
+ pop_out : QtCore.Signal
+ A Qt signal indicating a request to pop out the help widget.
+
+ open_in_browser : QtCore.Signal
+ A Qt signal indicating a request to open the help in a browser.
+
+ open_python_docs : QtCore.Signal
+ A Qt signal indicating a request to open the Python docstring
+ information.
+
+ report_jira_issue : QtCore.Signal
+ A Qt signal indicating a request to open the Jira issue reporting
+ widget.
+
+ toggle_help : QtCore.Signal
+ A Qt signal indicating a request to toggle the related help display
+ frame.
+ """
+ pop_out = QtCore.Signal()
+ open_in_browser = QtCore.Signal()
+ open_python_docs = QtCore.Signal()
+ report_jira_issue = QtCore.Signal()
+ toggle_help = QtCore.Signal(bool)
+
+ def __init__(self, icon="question", parent=None):
+ super().__init__(icon, parent=parent)
+ self.setCheckable(True)
+
+ def _clicked(self):
+ """Hook for QToolButton.clicked."""
+ self.toggle_help.emit(self.isChecked())
+
+ def generate_context_menu(self):
+ menu = QtWidgets.QMenu(parent=self)
+
+ if utils.HELP_WEB_ENABLED:
+ pop_out_docs = menu.addAction("Pop &out documentation...")
+ pop_out_docs.triggered.connect(self.pop_out.emit)
+
+ open_in_browser = menu.addAction("Open in &browser...")
+ open_in_browser.triggered.connect(self.open_in_browser.emit)
+
+ open_python_docs = menu.addAction("Open &Python docs...")
+ open_python_docs.triggered.connect(self.open_python_docs.emit)
+
+ def toggle():
+ self.setChecked(not self.isChecked())
+ self._clicked()
+
+ if utils.HELP_WEB_ENABLED:
+ toggle_help = menu.addAction("Toggle &help")
+ toggle_help.triggered.connect(toggle)
+
+ if utils.JIRA_URL:
+ menu.addSeparator()
+ report_issue = menu.addAction("&Report Jira issue...")
+ report_issue.triggered.connect(self.report_jira_issue.emit)
+
+ return menu
+
+
+class TyphosHelpFrame(QtWidgets.QFrame, widgets.TyphosDesignerMixin):
+ """
+ A frame for help information display.
+
+ Attributes
+ ----------
+ tooltip_updated : QtCore.Signal
+ A signal indicating the help tooltip has changed.
+ """
+ tooltip_updated = QtCore.Signal(str)
+
+ def __init__(self, parent=None):
+ super().__init__(parent=parent)
+ self.help = None
+ self.help_web_view = None
+ self._delete_timer = None
+ self.python_docs_browser = None
+
+ self.setContentsMargins(0, 0, 0, 0)
+
+ layout = QtWidgets.QVBoxLayout()
+ self.setLayout(layout)
+ self.devices = []
+ self._jira_widget = None
+
+ def new_jira_widget(self):
+ """Open a new Jira issue reporting widget."""
+ device = self.devices[0] if self.devices else None
+ self._jira_widget = TyphosJiraIssueWidget(device=device)
+ self._jira_widget.show()
+
+ def open_in_browser(self, new=0, autoraise=True):
+ """
+ Open the associated help documentation in the browser.
+
+ Parameters
+ ----------
+ new : int, optional
+ 0: the same browser window (the default).
+ 1: a new browser window.
+ 2: a new browser page ("tab").
+
+ autoraise : bool, optional
+ If possible, autoraise raises the window (the default) or not.
+ """
+ return webbrowser.open(
+ self.help_url.toString(), new=new, autoraise=autoraise
+ )
+
+ def open_python_docs(self, show: bool = True):
+ """Open the Python docstring information in a new window."""
+ if self.python_docs_browser is not None:
+ if show:
+ self.python_docs_browser.show()
+ self.python_docs_browser.raise_()
+ else:
+ self.python_docs_browser.hide()
+ return
+
+ if not show:
+ return
+
+ self.python_docs_browser = QtWidgets.QTextBrowser()
+ help_document = QtGui.QTextDocument()
+ contents = self._tooltip or "Unset"
+ first_line = contents.splitlines()[0]
+ # TODO: later versions of qt will support setMarkdown
+ help_document.setPlainText(contents)
+ self.python_docs_browser.setWindowTitle(first_line)
+ font = QtGui.QFont("Monospace")
+ font.setStyleHint(QtGui.QFont.TypeWriter)
+ # font.setStyleHint(QtGui.QFont.Monospace)
+ self.python_docs_browser.setFont(font)
+ self.python_docs_browser.setDocument(help_document)
+ self.python_docs_browser.show()
+ return self.python_docs_browser
+
+ def _get_tooltip(self):
+ """Update the tooltip based on device information."""
+ tooltip = []
+ # BUG: I'm seeing two devices in `self.devices` for
+ # $ typhos --fake-device 'ophyd.EpicsMotor[{"prefix":"b"}]'
+ for device in sorted(
+ set(self.devices),
+ key=lambda dev: self.devices.index(dev)
+ ):
+ heading = device.name or type(device).__name__
+ tooltip.extend([
+ heading,
+ "-" * len(heading),
+ ""
+ ])
+
+ tooltip.append(
+ inspect.getdoc(device) or
+ inspect.getdoc(type(device)) or
+ "No docstring"
+ )
+ tooltip.append("")
+
+ return "\n".join(tooltip)
+
+ def add_device(self, device):
+ self.devices.append(device)
+
+ self._tooltip = self._get_tooltip()
+ self.tooltip_updated.emit(self._tooltip)
+
+ self.setWindowTitle(f"Help: {device.name}")
+
+ @property
+ def help_url(self):
+ """The full help URL, generated from ``TYPHOS_HELP_URL``."""
+ if not self.devices or not utils.HELP_WEB_ENABLED:
+ return QtCore.QUrl("about:blank")
+
+ device, *_ = self.devices
+ try:
+ device_url = utils.HELP_URL.format(device=device)
+ except Exception:
+ logger.exception("Failed to format confluence URL for device %s",
+ device)
+ return QtCore.QUrl("about:blank")
+
+ return QtCore.QUrl(device_url)
+
+ def show_help(self):
+ """Show the help information in a QWebEngineView."""
+ if web.TyphosWebEngineView is None:
+ logger.error(
+ "Failed to import QWebEngineView; "
+ "help view is unavailable."
+ )
+ return
+
+ if self.help_web_view:
+ self.help_web_view.show()
+ return
+
+ self.help_web_view = web.TyphosWebEngineView()
+ self.help_web_view.page().setUrl(self.help_url)
+
+ self.help_web_view.setEnabled(True)
+ self.help_web_view.setMinimumSize(QtCore.QSize(100, 400))
+
+ self.layout().addWidget(self.help_web_view)
+
+ def hide_help(self):
+ """Hide the help information QWebEngineView."""
+ if not self.help_web_view:
+ return
+ self.help_web_view.hide()
+ if self._delete_timer is None:
+ self._delete_timer = QtCore.QTimer()
+ self._delete_timer.setInterval(20000)
+ self._delete_timer.setSingleShot(True)
+ self._delete_timer.timeout.connect(self._delete_help_if_hidden)
+ self._delete_timer.start()
+
+ def _delete_help_if_hidden(self):
+ """
+ Slowly react to the help display removal, as setting it back up can be
+ slow and painful.
+ """
+ self._delete_timer = None
+ if self.help_web_view and not self.help_web_view.isVisible():
+ self.layout().removeWidget(self.help_web_view)
+ self.help_web_view.deleteLater()
+ self.help_web_view = None
+
+ def toggle_help(self, show):
+ """
+ Toggle the visibility of the help information QWebEngineView.
+
+ Parameters
+ ----------
+ show : bool
+ Show the help (True) or hide it (False).
+ """
+ if not self.devices:
+ logger.warning("No devices added -> no help")
+ return
+
+ if show:
+ self.show_help()
+ else:
+ self.hide_help()
+
+
+
+[docs]
+class TyphosDisplayTitle(QtWidgets.QFrame, widgets.TyphosDesignerMixin):
+ """
+ Standardized Typhos Device Display title.
+
+ Parameters
+ ----------
+ title : str, optional
+ The initial title text, which may contain macros.
+
+ show_switcher : bool, optional
+ Show the :class:`TyphosDisplaySwitcher`.
+
+ show_underline : bool, optional
+ Show the underline separator.
+
+ parent : QtWidgets.QWidget, optional
+ The parent widget.
+ """
+
+ def __init__(self, title='${name}', *, show_switcher=True,
+ show_underline=True, parent=None):
+ self._show_underline = show_underline
+ self._show_switcher = show_switcher
+ super().__init__(parent=parent)
+
+ self.label = TyphosTitleLabel(title)
+ self.switcher = TyphosDisplaySwitcher()
+
+ self.underline = QtWidgets.QFrame()
+ self.underline.setFrameShape(self.underline.HLine)
+ self.underline.setFrameShadow(self.underline.Plain)
+ self.underline.setLineWidth(10)
+
+ self.notes_edit = TyphosNotesEdit()
+
+ self.grid_layout = QtWidgets.QGridLayout()
+ self.grid_layout.addWidget(self.label, 0, 0)
+ self.grid_layout.addWidget(self.switcher, 0, 2, Qt.AlignRight)
+ self.grid_layout.addWidget(self.notes_edit, 0, 1, Qt.AlignLeft)
+ self.grid_layout.addWidget(self.underline, 1, 0, 1, 3)
+
+ self.help = TyphosHelpFrame()
+ if utils.HELP_WEB_ENABLED:
+ # Toggle the help web view if we have documentation to show
+ self.switcher.help_toggle_button.toggle_help.connect(
+ self.toggle_help
+ )
+ else:
+ # Otherwise, open the python docs as a fallback
+ self.switcher.help_toggle_button.toggle_help.connect(
+ self.help.open_python_docs
+ )
+ self.switcher.help_toggle_button.pop_out.connect(self.pop_out_help)
+ self.switcher.help_toggle_button.open_in_browser.connect(
+ self.help.open_in_browser
+ )
+ self.switcher.help_toggle_button.open_python_docs.connect(
+ self.help.open_python_docs
+ )
+ self.switcher.help_toggle_button.report_jira_issue.connect(
+ self.help.new_jira_widget
+ )
+ self.help.tooltip_updated.connect(
+ self.switcher.help_toggle_button.setToolTip
+ )
+
+ self.grid_layout.addWidget(self.help, 2, 0, 1, 2)
+
+ self.grid_layout.setSizeConstraint(self.grid_layout.SetMinimumSize)
+ self.setLayout(self.grid_layout)
+
+ # Set the property:
+ self.show_switcher = show_switcher
+ self.show_underline = show_underline
+
+
+[docs]
+ def toggle_help(self, show):
+ """Toggle the help visibility."""
+ if self.help is None:
+ return
+
+ self.help.toggle_help(show)
+ if self.help.parent() is None:
+ self.grid_layout.addWidget(self.help, 2, 0, 1, 2)
+
+
+
+[docs]
+ def pop_out_help(self):
+ """Pop out the help widget."""
+ if self.help is None:
+ return
+
+ self.help.setParent(None)
+ self.switcher.help_toggle_button.setChecked(True)
+ self.help.show_help()
+ self.help.show()
+ self.help.raise_()
+
+
+ @Property(bool)
+ def show_switcher(self):
+ """Get or set whether to show the display switcher."""
+ return self._show_switcher
+
+ @show_switcher.setter
+ def show_switcher(self, value):
+ self._show_switcher = bool(value)
+ self.switcher.setVisible(self._show_switcher)
+
+
+[docs]
+ def add_device(self, device):
+ """Typhos hook for setting the associated device."""
+ if not self.label.text():
+ self.label.setText(device.name)
+
+ if not self.notes_edit.text():
+ self.notes_edit.setup_data(device.name)
+
+ if self.help is not None:
+ self.help.add_device(device)
+
+
+ @QtCore.Property(bool)
+ def show_underline(self):
+ """Get or set whether to show the underline."""
+ return self._show_underline
+
+ @show_underline.setter
+ def show_underline(self, value):
+ self._show_underline = bool(value)
+ self.underline.setVisible(self._show_underline)
+
+
+[docs]
+ def set_device_display(self, display):
+ """Typhos callback: set the :class:`TyphosDeviceDisplay`."""
+ self.device_display = display
+
+ def toggle():
+ toggle_display(display.display_widget)
+
+ self.label.toggle_requested.connect(toggle)
+
+
+ # Make designable properties from the title label available here as well
+ label_alignment = forward_property('label', QtWidgets.QLabel, 'alignment')
+ label_font = forward_property('label', QtWidgets.QLabel, 'font')
+ label_indent = forward_property('label', QtWidgets.QLabel, 'indent')
+ label_margin = forward_property('label', QtWidgets.QLabel, 'margin')
+ label_openExternalLinks = forward_property('label', QtWidgets.QLabel,
+ 'openExternalLinks')
+ label_pixmap = forward_property('label', QtWidgets.QLabel, 'pixmap')
+ label_text = forward_property('label', QtWidgets.QLabel, 'text')
+ label_textFormat = forward_property('label', QtWidgets.QLabel,
+ 'textFormat')
+ label_textInteractionFlags = forward_property('label', QtWidgets.QLabel,
+ 'textInteractionFlags')
+ label_wordWrap = forward_property('label', QtWidgets.QLabel, 'wordWrap')
+
+ # Make designable properties from the grid_layout
+ layout_margin = forward_property('grid_layout', QtWidgets.QHBoxLayout,
+ 'margin')
+ layout_spacing = forward_property('grid_layout', QtWidgets.QHBoxLayout,
+ 'spacing')
+
+ # Make designable properties from the underline
+ underline_palette = forward_property('underline', QtWidgets.QFrame,
+ 'palette')
+ underline_styleSheet = forward_property('underline', QtWidgets.QFrame,
+ 'styleSheet')
+ underline_lineWidth = forward_property('underline', QtWidgets.QFrame,
+ 'lineWidth')
+ underline_midLineWidth = forward_property('underline', QtWidgets.QFrame,
+ 'midLineWidth')
+
+
+
+
+[docs]
+class TyphosDeviceDisplay(utils.TyphosBase, widgets.TyphosDesignerMixin,
+ _DisplayTypes):
+ """
+ Main display for a single ophyd Device.
+
+ This contains the widgets for all of the root devices signals, and any
+ methods you would like to display. By typhos convention, the base
+ initialization sets up the widgets and the :meth:`.from_device` class
+ method will automatically populate the resulting display.
+
+ Parameters
+ ----------
+ parent : QWidget, optional
+ The parent widget.
+
+ scrollable : bool, optional
+ Semi-deprecated parameter. Use scroll_option instead.
+ If ``True``, put the loaded template into a :class:`QScrollArea`.
+ If ``False``, the display widget will go directly in this widget's
+ layout.
+ If omitted, scroll_option is used instead.
+
+ embedded_templates : list, optional
+ List of embedded templates to use in addition to those found on disk.
+
+ detailed_templates : list, optional
+ List of detailed templates to use in addition to those found on disk.
+
+ engineering_templates : list, optional
+ List of engineering templates to use in addition to those found on
+ disk.
+
+ display_type : DisplayTypes, str, or int, optional
+ The default display type.
+
+ scroll_option : ScrollOptions, str, or int, optional
+ The scroll behavior.
+
+ nested : bool, optional
+ An optional annotation for a display that may be nested inside another.
+ """
+
+ # Template types and defaults
+ Q_ENUMS(_DisplayTypes)
+ TemplateEnum = DisplayTypes # For convenience
+ template_changed = QtCore.Signal(object)
+ templates_loaded = QtCore.Signal(object)
+ templates: Dict[str, List[pathlib.Path]]
+
+ def __init__(
+ self,
+ parent: Optional[QtWidgets.QWidget] = None,
+ *,
+ scrollable: Optional[bool] = None,
+ embedded_templates: Optional[list[str]] = None,
+ detailed_templates: Optional[list[str]] = None,
+ engineering_templates: Optional[list[str]] = None,
+ display_type: Union[DisplayTypes, str, int] = 'detailed_screen',
+ scroll_option: Union[ScrollOptions, str, int] = 'auto',
+ nested: bool = False,
+ ):
+ self._current_template = None
+ self._forced_template = ''
+ self._macros = {}
+ self._display_widget = None
+ self._scroll_option = ScrollOptions.no_scroll
+ self._searched = False
+ self._hide_empty = False
+ self._nested = nested
+
+ self.templates = {name: [] for name in DisplayTypes.names}
+ self._display_type = normalize_display_type(display_type)
+
+ if nested and self._display_type == DisplayTypes.detailed_screen:
+ # All nested displays should be embedded by default.
+ # Based on if they have subdevices, they may become detailed
+ # during the template loading process
+ self._display_type = DisplayTypes.embedded_screen
+
+ instance_templates = {
+ 'embedded_screen': embedded_templates or [],
+ 'detailed_screen': detailed_templates or [],
+ 'engineering_screen': engineering_templates or [],
+ }
+ for view, path_list in instance_templates.items():
+ paths = [pathlib.Path(p).expanduser().resolve() for p in path_list]
+ self.templates[view].extend(paths)
+
+ self._scroll_area = QtWidgets.QScrollArea()
+ self._scroll_area.setAlignment(Qt.AlignTop)
+ self._scroll_area.setObjectName('scroll_area')
+ self._scroll_area.setFrameShape(QtWidgets.QFrame.StyledPanel)
+ self._scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ self._scroll_area.setWidgetResizable(True)
+ self._scroll_area.setFrameStyle(QtWidgets.QFrame.NoFrame)
+
+ super().__init__(parent=parent)
+
+ layout = QtWidgets.QHBoxLayout()
+ self.setLayout(layout)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._scroll_area)
+
+ if scrollable is None:
+ self.scroll_option = scroll_option
+ else:
+ if scrollable:
+ self.scroll_option = ScrollOptions.scrollbar
+ else:
+ self.scroll_option = ScrollOptions.no_scroll
+
+ @Property(_ScrollOptions)
+ def scroll_option(self) -> ScrollOptions:
+ """Place the display in a scrollable area."""
+ return self._scroll_option
+
+ @scroll_option.setter
+ def scroll_option(self, scrollable: ScrollOptions):
+ # Switch the scroll area behavior
+ opt = normalize_scroll_option(scrollable)
+ if opt == self._scroll_option:
+ return
+
+ self._scroll_option = opt
+ self._move_display_to_layout(self._display_widget)
+
+ @Property(bool)
+ def hideEmpty(self):
+ """Toggle hiding or showing empty panels."""
+ return self._hide_empty
+
+ @hideEmpty.setter
+ def hideEmpty(self, checked):
+ if checked != self._hide_empty:
+ self._hide_empty = checked
+
+ @property
+ def _layout_in_scroll_area(self) -> bool:
+ """Layout the widget in the scroll area or not, based on settings."""
+ if self.scroll_option == ScrollOptions.auto:
+ if self.display_type == DisplayTypes.embedded_screen:
+ return False
+ return True
+ elif self.scroll_option == ScrollOptions.scrollbar:
+ return True
+ elif self.scroll_option == ScrollOptions.no_scroll:
+ return False
+ return True
+
+ def _move_display_to_layout(self, widget):
+ if not widget:
+ return
+
+ widget.setParent(None)
+
+ scrollable = self._layout_in_scroll_area
+ if scrollable:
+ self._scroll_area.setWidget(widget)
+ else:
+ layout: QtWidgets.QVBoxLayout = self.layout()
+ layout.addWidget(widget, alignment=QtCore.Qt.AlignTop)
+
+ self._scroll_area.setVisible(scrollable)
+
+ def _get_matching_templates_for_class(
+ self,
+ cls: type,
+ display_type: DisplayTypes,
+ ) -> List[pathlib.Path]:
+ """Get matching templates for the given class."""
+ class_name_prefix = f"{cls.__name__}."
+ return [
+ filename
+ for filename in self.templates[display_type.name]
+ if filename.name.startswith(class_name_prefix)
+ ]
+
+ def _generate_template_menu(self, base_menu: QtWidgets.QMenu) -> None:
+ """Generate the template switcher menu, adding it to ``base_menu``."""
+ dev = self.device
+ if dev is None:
+ return
+
+ actions: List[QtWidgets.QAction] = []
+
+ def add_template(filename: pathlib.Path) -> None:
+ def switch_template(*, filename: pathlib.Path = filename):
+ self.force_template = filename
+
+ action = base_menu.addAction(str(filename))
+ action.triggered.connect(switch_template)
+ actions.append(action)
+
+ if self.current_template == filename:
+ base_menu.setDefaultAction(action)
+
+ def add_header(label: str, icon: Optional[QtGui.QIcon] = None) -> None:
+ action = QtWidgets.QWidgetAction(base_menu)
+ label = QtWidgets.QLabel(label)
+ label.setObjectName("menu_template_section")
+ action.setDefaultWidget(label)
+ if icon is not None:
+ action.setIcon(icon)
+ base_menu.addAction(action)
+
+ self._refresh_templates()
+ seen = set()
+
+ for template_type in DisplayTypes:
+ added_header = False
+ for cls in type(dev).mro():
+ matching = self._get_matching_templates_for_class(cls, template_type)
+ templates = set(matching) - seen
+ if not templates:
+ continue
+
+ def by_match_order(template: pathlib.Path) -> int:
+ return matching.index(template)
+
+ if not added_header:
+ add_header(
+ f"{template_type.friendly_name} screens",
+ icon=TyphosToolButton.get_icon(
+ TyphosDisplaySwitcherButton.icons[template_type.name]
+ ),
+ )
+ added_header = True
+
+ base_menu.addSection(f"{cls.__name__}")
+ for filename in sorted(templates, key=by_match_order):
+ add_template(filename)
+
+ add_header("Typhos default screens")
+ for template in DEFAULT_TEMPLATES_FLATTEN:
+ add_template(template)
+
+ prefix = os.path.commonprefix(
+ [action.text() for action in actions]
+ )
+ # Arbitrary threshold: saving on a few characters is not worth it
+ if len(prefix) > 9:
+ for action in actions:
+ action.setText(action.text()[len(prefix):])
+
+ def _refresh_templates(self):
+ """Force an update of the display cache and look for new ui files."""
+ cache.get_global_display_path_cache().update()
+ self.search_for_templates()
+
+ @property
+ def current_template(self):
+ """Get the current template being displayed."""
+ return self._current_template
+
+ @Property(_DisplayTypes)
+ def display_type(self):
+ """Get or set the current display type."""
+ return self._display_type
+
+ @display_type.setter
+ def display_type(self, value):
+ value = normalize_display_type(value)
+ if self._display_type != value:
+ self._display_type = value
+ self.load_best_template()
+
+ @property
+ def macros(self):
+ """Get or set the macros for the display."""
+ return dict(self._macros)
+
+ @macros.setter
+ def macros(self, macros):
+ self._macros.clear()
+ self._macros.update(**(macros or {}))
+
+ # If any display macros are specified, re-search for templates:
+ if any(view in self._macros for view in DisplayTypes.names):
+ self.search_for_templates()
+
+ @Property(str, designable=False)
+ def device_class(self):
+ """Get the full class with module name of loaded device."""
+ device = self.device
+ cls = self.device.__class__
+ return f'{cls.__module__}.{cls.__name__}' if device else ''
+
+ @Property(str, designable=False)
+ def device_name(self):
+ """Get the name of the loaded device."""
+ device = self.device
+ return device.name if device else ''
+
+ @property
+ def device(self):
+ """Get the device associated with this Device Display."""
+ try:
+ device, = self.devices
+ return device
+ except ValueError:
+ ...
+
+
+[docs]
+ def get_best_template(self, display_type, macros):
+ """
+ Get the best template for the given display type.
+
+ Parameters
+ ----------
+ display_type : DisplayTypes, str, or int
+ The display type.
+
+ macros : dict
+ Macros to use when loading the template.
+ """
+ display_type = normalize_display_type(display_type).name
+
+ templates = self.templates[display_type]
+ if templates:
+ return templates[0]
+
+ logger.warning("No templates available for display type: %s",
+ self._display_type)
+
+
+ def _remove_display(self):
+ """Remove the display widget, readying for a new template."""
+ display_widget = self._display_widget
+ if display_widget:
+ if self._scroll_area.widget():
+ self._scroll_area.takeWidget()
+ self.layout().removeWidget(display_widget)
+ display_widget.deleteLater()
+
+ self._display_widget = None
+
+
+[docs]
+ def load_best_template(self):
+ """Load the best available template for the current display type."""
+ if self.layout() is None:
+ # If we are not fully initialized yet do not try and add anything
+ # to the layout. This will happen if the QApplication has a
+ # stylesheet that forces a template prior to the creation of this
+ # display
+ return
+
+ if not self._searched:
+ self.search_for_templates()
+
+ self._remove_display()
+
+ template = (self._forced_template or
+ self.get_best_template(self._display_type, self.macros))
+
+ if not template:
+ widget = QtWidgets.QWidget()
+ widget.setObjectName("no_template_standin")
+ template = None
+ else:
+ template = pathlib.Path(template)
+ try:
+ widget = self._load_template(template)
+ except Exception as ex:
+ logger.exception("Unable to load file %r", template)
+ # If we have a previously defined template
+ if self._current_template is not None:
+ # Fallback to it so users have a choice
+ try:
+ widget = self._load_template(self._current_template)
+ except Exception:
+ logger.exception(
+ "Failed to fall back to previous template: %s",
+ self._current_template
+ )
+ template = None
+ widget = None
+
+ pydm.exception.raise_to_operator(ex)
+ else:
+ widget = QtWidgets.QWidget()
+ widget.setObjectName("errored_load_standin")
+ template = None
+
+ if widget:
+ if widget.objectName():
+ widget.setObjectName(f'{widget.objectName()}_display_widget')
+ else:
+ widget.setObjectName('display_widget')
+
+ if widget.layout() is None and widget.minimumSize().width() == 0:
+ # If the widget has no layout, use a fixed size for it.
+ # Without this, the widget may not display at all.
+ widget.setMinimumSize(widget.size())
+
+ self._display_widget = widget
+ self._current_template = template
+
+ def size_hint(*args, **kwargs):
+ return widget.size()
+
+ # sizeHint is not defined so we suggest the widget size
+ widget.sizeHint = size_hint
+
+ # We should _move_display_to_layout as soon as it is created. This
+ # allow us to speed up since if the widget is too complex it takes
+ # seconds to set it to the QScrollArea
+ self._move_display_to_layout(self._display_widget)
+
+ self._update_children()
+ utils.reload_widget_stylesheet(self)
+ self.updateGeometry()
+ self.template_changed.emit(template)
+
+
+
+[docs]
+ def minimumSizeHint(self) -> QtCore.QSize:
+ if self._layout_in_scroll_area:
+ return QtCore.QSize(
+ int(self._scroll_area.viewportSizeHint().width() * 1.05),
+ super().minimumSizeHint().height(),
+ )
+ return super().minimumSizeHint()
+
+
+ @property
+ def display_widget(self):
+ """Get the widget generated from the template."""
+ return self._display_widget
+
+ @staticmethod
+ def _get_templates_from_macros(macros):
+ ret = {}
+ paths = cache.get_global_display_path_cache().paths
+ for display_type in DisplayTypes.names:
+ ret[display_type] = None
+ try:
+ value = macros[display_type]
+ except KeyError:
+ ...
+ else:
+ if not value:
+ continue
+ try:
+ value = pathlib.Path(value)
+ except ValueError as ex:
+ logger.debug('Invalid path specified in macro: %s=%s',
+ display_type, value, exc_info=ex)
+ else:
+ ret[display_type] = list(utils.find_file_in_paths(
+ value, paths=paths))
+
+ return ret
+
+ def _load_template(self, filename):
+ """Load template from file and return the widget."""
+ filename = pathlib.Path(filename)
+ loader = (pydm.display.load_py_file if filename.suffix == '.py'
+ else utils.load_ui_file)
+
+ logger.debug('Load template using %s: %r', loader.__name__, filename)
+ try:
+ return loader(str(filename), macros=self._macros)
+ except Exception as ex:
+ display: Optional[pydm.Display] = getattr(ex, "pydm_display", None)
+ if display is not None:
+ display.setObjectName("_typhos_test_suite_ignore_")
+ display.close()
+ display.deleteLater()
+ raise
+
+ def _update_children(self):
+ """Notify child widgets of this device display + the device."""
+ device = self.device
+ display = self._display_widget
+ designer = display.findChildren(widgets.TyphosDesignerMixin) or []
+ bases = display.findChildren(utils.TyphosBase) or []
+
+ for widget in set(bases + designer):
+ if device and hasattr(widget, 'add_device'):
+ widget.add_device(device)
+
+ if hasattr(widget, 'set_device_display'):
+ widget.set_device_display(self)
+
+ @Property(str)
+ def force_template(self):
+ """Force a specific template."""
+ return self._forced_template
+
+ @force_template.setter
+ def force_template(self, value):
+ if value != self._forced_template:
+ self._forced_template = value
+ self.load_best_template()
+
+ @staticmethod
+ def _build_macros_from_device(device, macros=None):
+ result = {}
+ if hasattr(device, 'md'):
+ if isinstance(device.md, dict):
+ result = dict(device.md)
+ else:
+ result = dict(device.md.post())
+
+ if 'name' not in result:
+ result['name'] = device.name
+ if 'prefix' not in result and hasattr(device, 'prefix'):
+ result['prefix'] = device.prefix
+
+ result.update(**(macros or {}))
+ return result
+
+
+[docs]
+ def add_device(self, device, macros=None):
+ """
+ Add a Device and signals to the TyphosDeviceDisplay.
+
+ The full dictionary of macros is built with the following order of
+ precedence::
+
+ 1. Macros from the device metadata itself.
+ 2. If available, `name`, and `prefix` will be added from the device.
+ 3. The argument ``macros`` is then used to fill/update the final
+ macro dictionary.
+
+ This will also register the device's signals in the sig:// plugin.
+ This means that any templates can refer to their device's signals by
+ name.
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ The device to add.
+
+ macros : dict, optional
+ Additional macros to use/replace the defaults.
+ """
+ # We only allow one device at a time
+ if self.devices:
+ logger.debug("Removing devices %r", self.devices)
+ self.devices.clear()
+ # Add the device to the cache
+ super().add_device(device)
+ logger.debug("Registering signals from device %s", device.name)
+ for component_walk in device.walk_signals():
+ register_signal(component_walk.item)
+ self._searched = False
+ self.macros = self._build_macros_from_device(device, macros=macros)
+ self.load_best_template()
+
+ if not self.windowTitle():
+ self.setWindowTitle(getattr(device, "name", ""))
+
+
+
+[docs]
+ def search_for_templates(self):
+ """Search the filesystem for device-specific templates."""
+ device = self.device
+ if not device:
+ logger.debug('Cannot search for templates without device')
+ return
+
+ self._searched = True
+ cls = device.__class__
+
+ logger.debug('Searching for templates for %s', cls.__name__)
+ macro_templates = self._get_templates_from_macros(self._macros)
+
+ paths = cache.get_global_display_path_cache().paths
+ for display_type in DisplayTypes.names:
+ view = display_type
+ if view.endswith('_screen'):
+ view = view.split('_screen')[0]
+
+ template_list = self.templates[display_type]
+ template_list.clear()
+
+ # 1. Highest priority: macros
+ for template in set(macro_templates[display_type] or []):
+ template_list.append(template)
+ logger.debug('Adding macro template %s: %s (total=%d)',
+ display_type, template, len(template_list))
+
+ # 2. Templates based on class hierarchy names
+ filenames = utils.find_templates_for_class(cls, view, paths)
+ for filename in filenames:
+ if filename not in template_list:
+ template_list.append(filename)
+ logger.debug('Found new template %s: %s (total=%d)',
+ display_type, filename, len(template_list))
+
+ # 3. Ensure that the detailed tree template makes its way in for
+ # all top-level screens, if no class-specific screen exists
+ if DETAILED_TREE_TEMPLATE not in template_list:
+ if not self._nested or self.suggest_composite_screen(cls):
+ template_list.append(DETAILED_TREE_TEMPLATE)
+
+ # 4. Default templates
+ template_list.extend(
+ [templ for templ in DEFAULT_TEMPLATES[display_type]
+ if templ not in template_list]
+ )
+
+ self.templates_loaded.emit(copy.deepcopy(self.templates))
+
+
+
+[docs]
+ @classmethod
+ def suggest_composite_screen(cls, device_cls):
+ """
+ Suggest to use the composite screen for the given class.
+
+ Returns
+ -------
+ composite : bool
+ If True, favor the composite screen.
+ """
+ for _, component in utils._get_top_level_components(device_cls):
+ if issubclass(component.cls, ophyd.Device):
+ return True
+ return False
+
+
+
+[docs]
+ @classmethod
+ def from_device(cls, device, template=None, macros=None, **kwargs):
+ """
+ Create a new TyphosDeviceDisplay from a Device.
+
+ Loads the signals in to the appropriate positions and sets the title to
+ a cleaned version of the device name
+
+ Parameters
+ ----------
+ device : ophyd.Device
+
+ template : str, optional
+ Set the ``display_template``.
+
+ macros : dict, optional
+ Macro substitutions to be placed in template.
+
+ **kwargs
+ Passed to the class init.
+ """
+ display = cls(**kwargs)
+ # Reset the template if provided
+ if template:
+ display.force_template = template
+ # Add the device
+ display.add_device(device, macros=macros)
+ return display
+
+
+
+[docs]
+ @classmethod
+ def from_class(cls, klass, *, template=None, macros=None, **kwargs):
+ """
+ Create a new TyphosDeviceDisplay from a Device class.
+
+ Loads the signals in to the appropriate positions and sets the title to
+ a cleaned version of the device name.
+
+ Parameters
+ ----------
+ klass : str or class
+
+ template : str, optional
+ Set the ``display_template``.
+
+ macros : dict, optional
+ Macro substitutions to be placed in template.
+
+ **kwargs
+ Extra arguments are used at device instantiation.
+
+ Returns
+ -------
+ TyphosDeviceDisplay
+ """
+ try:
+ obj = pcdsutils.utils.get_instance_by_name(klass, **kwargs)
+ except Exception:
+ logger.exception('Failed to generate TyphosDeviceDisplay from '
+ 'class %s', klass)
+ return None
+
+ return cls.from_device(obj, template=template, macros=macros)
+
+
+ @classmethod
+ def _get_specific_screens(cls, device_cls):
+ """
+ Get the list of specific screens for a given device class.
+
+ That is, screens that are not default Typhos-provided screens.
+ """
+ paths = cache.get_global_display_path_cache().paths
+ return [
+ template
+ for template in utils.find_templates_for_class(
+ device_cls, "detailed", paths
+ )
+ if not utils.is_standard_template(template)
+ ]
+
+
+[docs]
+ def to_image(self):
+ """
+ Return the entire display as a QtGui.QImage.
+
+ Returns
+ -------
+ QtGui.QImage
+ The display, as an image.
+ """
+ if self._display_widget is not None:
+ return utils.widget_to_image(self._display_widget)
+
+
+
+[docs]
+ @Slot()
+ def copy_to_clipboard(self):
+ """Copy the display image to the clipboard."""
+ image = self.to_image()
+ if image is not None:
+ clipboard = QtGui.QGuiApplication.clipboard()
+ clipboard.setImage(image)
+
+
+ @Slot(object)
+ def _tx(self, value):
+ """Receive information from happi channel."""
+ self.add_device(value['obj'], macros=value['md'])
+
+ def __repr__(self):
+ """Get a custom representation for TyphosDeviceDisplay."""
+ return (
+ f'<{self.__class__.__name__} at {hex(id(self))} '
+ f'device={self.device_class}[{self.device_name!r}] '
+ f'nested={self._nested}'
+ f'>'
+ )
+
+
+
+
+[docs]
+def toggle_display(widget, force_state=None):
+ """
+ Toggle the visibility of all :class:`TyphosSignalPanel` in a display.
+
+ Parameters
+ ----------
+ widget : QWidget
+ The widget in which to look for Panels.
+
+ force_state : bool
+ If set to True or False, it will change visibility to the value of
+ force_state.
+ If not set or set to None, it will flip the current panels state.
+ """
+ panels = widget.findChildren(typhos_panel.TyphosSignalPanel) or []
+ visible = all(panel.isVisible() for panel in panels)
+
+ state = not visible
+ if force_state is not None:
+ state = force_state
+
+ for panel in panels:
+ panel.setVisible(state)
+
+
+
+
+[docs]
+def show_empty(widget):
+ """
+ Recursively shows all panels and widgets, empty or not.
+
+ Parameters
+ ----------
+ widget : QWidget
+ """
+ children = widget.findChildren(TyphosDeviceDisplay) or []
+ for ch in children:
+ show_empty(ch)
+ widget.setVisible(True)
+ toggle_display(widget, force_state=True)
+
+
+
+
+[docs]
+def hide_empty(widget, process_widget=True):
+ """
+ Recursively hide empty panels and widgets.
+
+ Parameters
+ ----------
+ widget : QWidget
+ The widget in which to start the recursive search.
+
+ process_widget : bool
+ Whether or not to process the visibility for the widget.
+ This is useful since we don't want to hide the top-most
+ widget otherwise users can't change the visibility back on.
+ """
+ def process(item, recursive=True):
+ if isinstance(item, TyphosDeviceDisplay) and recursive:
+ hide_empty(item)
+ elif isinstance(item, typhos_panel.TyphosSignalPanel):
+ if recursive:
+ hide_empty(item)
+ visible = bool(item._panel_layout.visible_elements)
+ item.setVisible(visible)
+
+ if isinstance(widget, TyphosDeviceDisplay):
+ # Check if the template at this display is one of the defaults
+ # otherwise we are not sure if we can safely change it.
+
+ if widget.current_template not in DEFAULT_TEMPLATES_FLATTEN:
+ logger.info("Can't hide empty entries in non built-in templates")
+ return
+
+ children = widget.findChildren(utils.TyphosBase) or []
+ for w in children:
+ process(w)
+
+ if process_widget:
+ if isinstance(widget, TyphosDeviceDisplay):
+ overall_status = any(w.isVisible() for w in children)
+ elif isinstance(widget, typhos_panel.TyphosSignalPanel):
+ overall_status = bool(widget._panel_layout.visible_elements)
+ widget.setVisible(overall_status)
+
+
+"""
+Display an arbitrary Python function inside our PyQt UI.
+
+The class :class:`.FunctionDisplay` uses the function annotation language
+described in PEP 3107 to automatically create a widget based on the arguments
+and keywords contained within.
+
+To keep track of parameter information subclassed versions of QWidgets are
+instantiated. Each one is expected to keep track of the parameter it controls
+with the attribute ``parameter``, and each one should return the present value
+with the correct type with the method `get_param_value``. There may be cases
+where these widgets find that the user has entered inappropriate values, in
+this case they should return np.nan to halt the function from being called.
+"""
+import inspect
+import logging
+from functools import partial
+
+import numpy as np
+from numpydoc import docscrape
+from qtpy.QtCore import Property, QSize, Qt, Slot
+from qtpy.QtGui import QFont
+from qtpy.QtWidgets import (QCheckBox, QGroupBox, QHBoxLayout, QLabel,
+ QLineEdit, QPushButton, QSizePolicy, QSpacerItem,
+ QVBoxLayout, QWidget)
+
+from .status import TyphosStatusThread
+from .utils import clean_attr, raise_to_operator
+from .widgets import TogglePanel, TyphosDesignerMixin
+
+logger = logging.getLogger(__name__)
+
+
+class ParamWidget(QWidget):
+ """
+ Generic Parameter Widget.
+
+ This creates the QLabel for the parameter and defines the interface
+ required for subclasses of the ParamWidget.
+ """
+ def __init__(self, parameter, default=inspect._empty, parent=None):
+ super().__init__(parent=parent)
+ # Store parameter information
+ self.parameter = parameter
+ self.default = default
+ self.setLayout(QHBoxLayout())
+ # Create our label
+ self.param_label = QLabel(parent=self)
+ self.param_label.setText(clean_attr(parameter))
+ self.layout().addWidget(self.param_label)
+ # Indicate required parameters in bold font
+ if default == inspect._empty:
+ logger.debug("Inferring that %s has no default", parameter)
+ bold = QFont()
+ bold.setBold(True)
+ self.param_label.setFont(bold)
+
+ def get_param_value(self):
+ """Must be redefined by subclasses"""
+ raise NotImplementedError
+
+
+class ParamCheckBox(ParamWidget):
+ """
+ QCheckBox for operator control of boolean values.
+
+ Parameters
+ ----------
+ parameter : str
+ Name of parameter this widget controls.
+
+ default : bool, optional
+ Default state of the box.
+
+ parent : QWidget, optional
+ """
+ def __init__(self, parameter, default=inspect._empty, parent=None):
+ super().__init__(parameter, default=default, parent=parent)
+ self.param_control = QCheckBox(parent=self)
+ self.layout().addWidget(self.param_control)
+ # Configure default QCheckBox position
+ if default != inspect._empty:
+ self.param_control.setChecked(default)
+
+ def get_param_value(self):
+ """
+ Return the checked state of the QCheckBox.
+ """
+ return self.param_control.isChecked()
+
+
+class ParamLineEdit(ParamWidget):
+ """
+ QLineEdit for typed user entry control.
+
+ Parameter
+ ---------
+ parameter : str
+ Name of parameter this widget controls.
+
+ _type : type
+ Type to convert the text to before sending it to the function. All
+ values are initially `QString`s and then they are converted to the
+ specified type. If this raises a ``ValueError`` due to an improperly
+ entered value a ``np.nan`` is returned.
+
+ default : bool, optional
+ Default text for the QLineEdit. This if automatically populated into
+ the QLineEdit field and it is also set as the ``placeHolderText``.
+
+ parent : QWidget, optional
+ """
+ def __init__(self, parameter, _type, default='', parent=None):
+ super().__init__(parameter, default=default, parent=parent)
+ # Store type information
+ self._type = _type
+ # Create our LineEdit
+ # Set our default text
+ self.param_edit = QLineEdit(parent=self)
+ self.param_edit.setAlignment(Qt.AlignCenter)
+ self.layout().addWidget(self.param_edit)
+ # Configure default text of LineEdit
+ # If there is no default, still give some placeholder text
+ # to indicate the type of the command needed
+ if default != inspect._empty:
+ self.param_edit.setText(str(self.default))
+ self.param_edit.setPlaceholderText(str(self.default))
+ elif self._type in (int, float):
+ self.param_edit.setPlaceholderText(str(self._type(0.0)))
+
+ def get_param_value(self):
+ """
+ Return the current value of the QLineEdit converted to :attr:`._type`.
+ """
+ # Cast the current text into our type
+ try:
+ val = self._type(self.param_edit.text())
+ # If not possible, capture the exception and report `np.nan`
+ except ValueError:
+ logger.exception("Could not convert text to %r",
+ self._type.__name__)
+ val = np.nan
+ return val
+
+
+def parse_numpy_docstring(docstring):
+ '''
+ Parse a numpy docstring for summary and parameter information.
+
+ Parameters
+ ----------
+ docstring : str
+ Docstring to parse.
+
+ Returns
+ -------
+ info : dict
+ info['summary'] is a string summary.
+ info['params'] is a dictionary of parameter name to a list of
+ description lines.
+ '''
+ info = {}
+ parsed = docscrape.NumpyDocString(docstring)
+ info['summary'] = '\n'.join(parsed['Summary'])
+ params = parsed['Parameters']
+
+ # numpydoc v0.8.0 uses just a tuple for parameters, but later versions use
+ # a namedtuple. here, only assume a tuple:
+ info['params'] = {name: lines for name, type_, lines in params}
+ return info
+
+
+class FunctionDisplay(QGroupBox):
+ """
+ Display controls for an annotated function in a QGroupBox.
+
+ In order to display function arguments in the user interface, the class
+ must be aware of what the type is of each of the parameters. Instead of
+ requiring a user to enter this information manually, the class takes
+ advantage of the function annotation language described in PEP 3107. This
+ allows us to quickly create the appropriate widget for the given parameter
+ based on the type.
+
+ If a function parameter is not given an annotation, we will attempt to
+ infer it from the default value if available. If this is not possible, and
+ the type is not specified in the ``annotations`` dictionary an exception
+ will be raised.
+
+ The created user interface consists of a button to execute the function,
+ the required parameters are always displayed beneath the button, and
+ a :class:`.TogglePanel` object that toggles the view of the optional
+ parameters below.
+
+ Attributes
+ ----------
+ accepted_types : list
+ List of types FunctionDisplay can create widgets for.
+
+ Parameters
+ ----------
+ func : callable
+
+ name : str, optional
+ Name to label the box with, by default this will be the function
+ meeting.
+
+ annotations : dict, optional
+ If the function your are creating a display for is not annotated, you
+ may manually supply types for parameters by passing in a dictionary of
+ name to type mapping.
+
+ hide_params : list, optional
+ List of parameters to exclude from the display. These should have
+ appropriate defaults. By default, ``self``, ``args`` and ``kwargs`` are
+ all excluded.
+
+ parent : QWidget, optional
+ """
+ accepted_types = [bool, str, int, float]
+
+ def __init__(self, func, name=None, annotations=None,
+ hide_params=None, parent=None):
+ # Function information
+ self.func = func
+ self.signature = inspect.signature(func)
+ self.name = name or self.func.__name__
+ # Initialize parent
+ super().__init__(f'{clean_attr(self.name)} Parameters',
+ parent=parent)
+ # Ignore certain parameters, args and kwargs by default
+ self.hide_params = ['self', 'args', 'kwargs']
+ if hide_params:
+ self.hide_params.extend(hide_params)
+ # Create basic layout
+ self._layout = QVBoxLayout()
+ self._layout.setSpacing(2)
+ self.setLayout(self._layout)
+ # Create an empty list to fill later with parameter widgets
+ self.param_controls = list()
+ # Add our button to execute the function
+ self.execute_button = QPushButton()
+
+ self.docs = {'summary': func.__doc__ or '',
+ 'params': {}
+ }
+
+ if func.__doc__ is not None:
+ try:
+ self.docs.update(**parse_numpy_docstring(func.__doc__))
+ except Exception as ex:
+ logger.warning('Unable to parse docstring for function %s: %s',
+ name, ex, exc_info=ex)
+
+ self.execute_button.setToolTip(self.docs['summary'])
+
+ self.execute_button.setText(clean_attr(self.name))
+ self.execute_button.clicked.connect(self.execute)
+ self._layout.addWidget(self.execute_button)
+ # Add a panel for the optional parameters
+ self.optional = TogglePanel("Optional Parameters")
+ self.optional.contents = QWidget()
+ self.optional.contents.setLayout(QVBoxLayout())
+ self.optional.contents.layout().setSpacing(2)
+ self.optional.layout().addWidget(self.optional.contents)
+ self.optional.show_contents(False)
+ self._layout.addWidget(self.optional)
+ self._layout.addItem(QSpacerItem(10, 5, vPolicy=QSizePolicy.Expanding))
+ # Create parameters from function signature
+ annotations = annotations or dict()
+ for param in [param for param in self.signature.parameters.values()
+ if param.name not in self.hide_params]:
+ logger.debug("Adding parameter %s ", param.name)
+ # See if we received a manual annotation for this parameter
+ if param.name in annotations:
+ _type = annotations[param.name]
+ logger.debug("Found manually specified type %r",
+ _type.__name__)
+ # Try and get the type from the function annotation
+ elif param.annotation != inspect._empty:
+ _type = param.annotation
+ logger.debug("Found annotated type %r ",
+ _type.__name__)
+ # Try and get the type from the default value
+ elif param.default != inspect._empty:
+ _type = type(param.default)
+ logger.debug("Gathered type %r from parameter default ",
+ _type.__name__)
+ # If we don't have a default value or an annotation,
+ # we can not make a widget for this parameter. Since
+ # this is a required variable (no default), the function
+ # will not work without it. Raise an Exception
+ else:
+ raise TypeError("Parameter {} has an unspecified "
+ "type".format(param.name))
+
+ # Add our parameter
+ self.add_parameter(param.name, _type, default=param.default)
+ # Hide optional parameter widget if there are no such parameters
+ if not self.optional_params:
+ self.optional.hide()
+
+ @property
+ def required_params(self):
+ """
+ Required parameters.
+ """
+ parameters = self.signature.parameters
+ return [param.parameter for param in self.param_controls
+ if parameters[param.parameter].default == inspect._empty]
+
+ @property
+ def optional_params(self):
+ """
+ Optional parameters.
+ """
+ parameters = self.signature.parameters
+ return [param.parameter for param in self.param_controls
+ if parameters[param.parameter].default != inspect._empty]
+
+ @Slot()
+ def execute(self):
+ """
+ Execute :attr:`.func`.
+
+ This takes the parameters configured by the :attr:`.param_controls`
+ widgets and passes them into the given callable. All generated
+ exceptions are captured and logged.
+ """
+ logger.info("Executing %s ...", self.name)
+ # If our function does not take any argument
+ # just pass it on. Otherwise, collect information
+ # from the appropriate widgets
+ if not self.signature.parameters:
+ func = self.func
+ else:
+ kwargs = dict()
+ # Gather information from parameter widgets
+ for button in self.param_controls:
+ logger.debug("Gathering parameters for %s ...",
+ button.parameter)
+ val = button.get_param_value()
+ logger.debug("Received %s", val)
+ # Watch for NaN values returned from widgets
+ # This indicates that there was improper information given
+ if np.isnan(val):
+ logger.error("Invalid information supplied for %s "
+ "parameter", button.parameter)
+ return
+ kwargs[button.parameter] = val
+ # Button up function call with partial to try below
+ func = partial(self.func, **kwargs)
+ try:
+ # Execute our function
+ func()
+ except Exception:
+ logger.exception("Exception while executing function")
+ else:
+ logger.info("Operation Complete")
+
+ def add_parameter(self, name, _type, default=inspect._empty, tooltip=None):
+ """
+ Add a parameter to the function display.
+
+ Parameters
+ ----------
+ name : str
+ Parameter name.
+
+ _type : type
+ Type of variable that we are expecting the user to input.
+
+ default : any, optional
+ Default value for the parameter.
+
+ tooltip : str, optional
+ Tooltip to use for the control widget. If not specified, docstring
+ parameter information will be used if available to generate a
+ default.
+
+ Returns
+ -------
+ widget : QWidget
+ The generated widget.
+ """
+ if tooltip is None:
+ tooltip_header = f'{name} - {_type.__name__}'
+ tooltip = [
+ tooltip_header,
+ '-' * len(tooltip_header)
+ ]
+
+ if default != inspect._empty:
+ tooltip.append(f'Default: {default}')
+
+ try:
+ doc_param = self.docs['params'][name]
+ except KeyError:
+ logger.debug('Parameter information is not available '
+ 'for %s(%s)', self.name, name)
+ else:
+ if doc_param:
+ tooltip.extend(doc_param)
+
+ # If the tooltip is just the header, remove the dashes underneath:
+ if len(tooltip) == 2:
+ tooltip = tooltip[:1]
+ tooltip = '\n'.join(tooltip)
+
+ # Create our parameter control widget
+ # QCheckBox field
+ if _type == bool:
+ cntrl = ParamCheckBox(name, default=default)
+ else:
+ # Check if this is a valid type
+ if _type not in self.accepted_types:
+ raise TypeError("Parameter {} has type {} which can not "
+ "be represented in a widget"
+ "".format(name, _type.__name__))
+ # Create our QLineEdit
+ cntrl = ParamLineEdit(name, default=default, _type=_type)
+ # Add our button to the widget
+ # If it is required add it above the panel so that it is always
+ # visisble. Otherwise, add it to the hideable panel
+ self.param_controls.append(cntrl)
+ if default == inspect._empty:
+ self.layout().insertWidget(len(self.required_params), cntrl)
+ else:
+ # If this is the first optional parameter,
+ # show the whole optional panel
+ if self.optional.isHidden():
+ self.optional.show()
+ # Add the control widget to our contents
+ self.optional.contents.layout().addWidget(cntrl)
+
+ cntrl.param_label.setToolTip(tooltip)
+ return cntrl
+
+ def sizeHint(self):
+ """Suggested size."""
+ return QSize(175, 100)
+
+
+
+[docs]
+class FunctionPanel(TogglePanel):
+ """
+ Function Panel.
+
+ Similar to :class:`.SignalPanel` but instead displays a set of function
+ widgets arranged in a row. Each provided method has a
+ :class:`.FunctionDisplay` generated for it an added to the layout.
+
+ Parameters
+ ----------
+ methods : list of callables, optional
+ List of callables to add to the FunctionPanel.
+
+ parent : QWidget
+ """
+ def __init__(self, methods=None, parent=None):
+ # Initialize parent
+ super().__init__("Functions", parent=parent)
+ self.contents = QWidget()
+ self.layout().addWidget(self.contents)
+ # Create Layout
+ self.contents.setLayout(QHBoxLayout())
+ # Add two spacers to center our functions without
+ # expanding them
+ self.contents.layout().addItem(QSpacerItem(10, 20))
+ self.contents.layout().addItem(QSpacerItem(10, 20))
+ # Add methods
+ methods = methods or list()
+ self.methods = dict()
+ for method in methods:
+ self.add_method(method)
+
+
+[docs]
+ def add_method(self, func, *args, **kwargs):
+ """
+ Add a :class:`.FunctionDisplay`.
+
+ Parameters
+ ----------
+ func : callable
+ Annotated callable function.
+
+ args, kwargs:
+ All additional parameters are passed directly to the
+ :class:`.FunctionDisplay` constructor.
+ """
+ # Create method display
+ func_name = kwargs.get('name', func.__name__)
+ logger.debug("Adding method %s ...", func_name)
+ widget = FunctionDisplay(func, *args, **kwargs)
+ # Store for posterity
+ self.methods[func_name] = widget
+ # Add to panel. Make sure that if this is
+ # the first added method that the panel is visible
+ self.show_contents(True)
+ self.contents.layout().insertWidget(len(self.methods),
+ widget)
+
+
+
+
+
+[docs]
+class TyphosMethodButton(QPushButton, TyphosDesignerMixin):
+ """
+ QPushButton to access a method of a Device.
+
+ The function provided by the loaded device and the :attr:`.method_name`
+ will be run when the button is clicked. If ``use_status`` is set to True,
+ the button will be disabled while the ``Status`` object is active.
+ """
+ _min_visible_operation = 0.1
+ _max_allowed_operation = 10.0
+
+ def __init__(self, parent=None):
+ self._method = ''
+ self._use_status = False
+ super().__init__(parent=parent)
+ self._status_thread = None
+ self.clicked.connect(self.execute)
+ self.devices = list()
+
+
+[docs]
+ def add_device(self, device):
+ """
+ Add a new device to the widget.
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ """
+ logger.debug("Adding device %s ...", device.name)
+ self.devices.append(device)
+
+
+ @Property(str)
+ def method_name(self):
+ """Name of method on provided Device to execute."""
+ return self._method
+
+ @method_name.setter
+ def method_name(self, value):
+ self._method = value
+
+ @Property(bool)
+ def use_status(self):
+ """
+ Use the status to enable and disable the button.
+ """
+ return self._use_status
+
+ @use_status.setter
+ def use_status(self, value):
+ self._use_status = value
+
+
+[docs]
+ @Slot()
+ def execute(self):
+ """Execute the method given by ``method_name``."""
+ if not self.devices:
+ logger.error("No device loaded into the object")
+ return
+ device = self.devices[0]
+ logger.debug("Grabbing method %r from %r ...",
+ self.method_name, device.name)
+ try:
+ method = getattr(device, self.method_name)
+ logger.debug("Executing method ...")
+ status = method()
+ except Exception as exc:
+ logger.exception("Error executing method %r.",
+ self.method_name)
+ raise_to_operator(exc)
+ return
+ if self.use_status:
+ logger.debug("Tearing down any old status threads ...")
+ if self._status_thread and self._status_thread.isRunning():
+ # We should usually never reach this line of code because the
+ # button should be disabled while the status object is not
+ # done. However, it is good to catch this to make sure that we
+ # only have one active thread at a time
+ logger.debug("Removing running TyphosStatusThread!")
+ self._status_thread.disconnect()
+
+ self._status_thread = None
+ logger.debug("Setting up new status thread ...")
+ self._status_thread = TyphosStatusThread(
+ status, start_delay=self._min_visible_operation,
+ timeout=self._max_allowed_operation,
+ parent=self,
+ )
+
+ def status_started():
+ self.setEnabled(False)
+
+ def status_finished(result):
+ self.setEnabled(True)
+
+ self._status_thread.status_started.connect(status_started)
+ self._status_thread.status_finished.connect(status_finished)
+ # Re-enable the button if it's taking too long
+ self._status_thread.status_timeout.connect(status_finished)
+
+ logger.debug("Starting TyphosStatusThread ...")
+ self._status_thread.start()
+
+
+
+[docs]
+ @classmethod
+ def from_device(cls, device, parent=None):
+ """Create a TyphosMethodButton from a device."""
+ instance = cls(parent=parent)
+ instance.add_device(device)
+ return instance
+
+
+
+"""
+Layouts and container widgets that show a "panel" of signals.
+
+Layouts:
+ * :class:`SignalPanel`
+ * :class:`CompositeSignalPanel`
+
+Container widgets:
+ * :class:`TyphosSignalPanel`
+ * :class:`TyphosCompositeSignalPanel`
+"""
+
+from __future__ import annotations
+
+import functools
+import logging
+from functools import partial
+from typing import Dict, List, Optional
+
+import ophyd
+from ophyd import Kind
+from ophyd.signal import EpicsSignal, EpicsSignalRO
+from qtpy import QtCore, QtGui, QtWidgets
+from qtpy.QtCore import Q_ENUMS, Property
+
+from . import display, utils
+from .cache import get_global_widget_type_cache
+from .utils import TyphosBase
+from .widgets import SignalWidgetInfo, TyphosDesignerMixin
+
+logger = logging.getLogger(__name__)
+
+
+class SignalOrder:
+ """
+ Options for sorting signals.
+
+ This can be used as a base class for subclasses of
+ :class:`QtWidgets.QWidget`, allowing this to be used in
+ :class:`QtCore.Property` and therefore in the Qt designer.
+ """
+
+ byKind = 0
+ byName = 1
+
+
+DEFAULT_KIND_ORDER = (Kind.hinted, Kind.normal, Kind.config, Kind.omitted)
+
+
+def _get_component_sorter(signal_order, *, kind_order=None):
+ """
+ Get a sorting function for :class:`ophyd.device.ComponentWalk` entries.
+
+ Parameters
+ ----------
+ signal_order : SignalOrder
+ Order for signals.
+
+ kind_order : list, optional
+ Order for Kinds, defaulting to ``DEFAULT_KIND_ORDER``.
+ """
+ kind_order = kind_order or DEFAULT_KIND_ORDER
+
+ def kind_sorter(walk):
+ """Sort by kind."""
+ return (kind_order.index(walk.item.kind), walk.dotted_name)
+
+ def name_sorter(walk):
+ """Sort by name."""
+ return walk.dotted_name
+
+ return {SignalOrder.byKind: kind_sorter,
+ SignalOrder.byName: name_sorter
+ }.get(signal_order, name_sorter)
+
+
+class SignalPanelRowLabel(QtWidgets.QLabel):
+ """
+ A row label for a signal panel.
+
+ This subclass does not contain any special functionality currently, but
+ remains a special class for ease of stylesheet configuration and label
+ disambiguation.
+ """
+
+
+
+[docs]
+class SignalPanel(QtWidgets.QGridLayout):
+ """
+ Basic panel layout for :class:`ophyd.Signal` and other ophyd objects.
+
+ This panel does not support hierarchical display of signals; rather, it
+ flattens a device hierarchy showing all signals in the same area.
+
+ Parameters
+ ----------
+ signals : OrderedDict, optional
+ Signals to include in the panel.
+ Parent of panel.
+
+ Attributes
+ ----------
+ loading_complete : QtCore.Signal
+ A signal indicating that loading of the panel has completed.
+
+ NUM_COLS : int
+ The number of columns in the layout.
+
+ COL_LABEL : int
+ The column number for the row label.
+
+ COL_READBACK : int
+ The column number for the readback widget.
+
+ COL_SETPOINT : int
+ The column number for the setpoint widget.
+
+ See also
+ --------
+ :class:`CompositeSignalPanel`.
+ """
+
+ NUM_COLS = 3
+ COL_LABEL = 0
+ COL_READBACK = 1
+ COL_SETPOINT = 2
+
+ loading_complete = QtCore.Signal(list)
+
+ def __init__(self, signals=None):
+ super().__init__()
+
+ self.signal_name_to_info = {}
+ self._row_count = 0
+ self._devices = []
+
+ # Make sure setpoint/readback share space evenly
+ self.setColumnStretch(self.COL_READBACK, 1)
+ self.setColumnStretch(self.COL_SETPOINT, 1)
+
+ get_global_widget_type_cache().widgets_determined.connect(
+ self._got_signal_widget_info, QtCore.Qt.QueuedConnection)
+
+ if signals:
+ for name, sig in signals.items():
+ self.add_signal(sig, name)
+
+ @property
+ def signals(self):
+ """
+ Get all instantiated signals, omitting components.
+
+ Returns
+ -------
+ signals : dict
+ With the form: ``{signal_name: signal}``.
+ """
+ return {
+ name: info['signal']
+ for name, info in list(self.signal_name_to_info.items())
+ if info['signal'] is not None
+ }
+
+ @property
+ def visible_signals(self):
+ """
+ Get all signals visible according to filters, omitting components.
+
+ Returns
+ -------
+ signals : dict
+ With the form: ``{signal_name: signal}``.
+ """
+ return {
+ name: info['signal']
+ for name, info in list(self.signal_name_to_info.items())
+ if info['signal'] is not None and info['visible']
+ }
+
+ visible_elements = visible_signals
+
+ @property
+ def row_count(self):
+ """Get the number of filled-in rows."""
+ return self._row_count
+
+ @QtCore.Slot(object, SignalWidgetInfo)
+ def _got_signal_widget_info(self, obj, info):
+ """
+ Slot: Received information on how to make widgets for ``obj``.
+
+ Parameters
+ ----------
+ obj : ophyd.OphydObj
+ The object that corresponds to the given widget information.
+
+ info : SignalWidgetInfo
+ The associated widget information.
+ """
+ try:
+ sig_info = self.signal_name_to_info[obj.name]
+ except KeyError:
+ return
+
+ if sig_info['widget_info'] is not None:
+ # Only add widgets on the first callback
+ # TODO: debug why multiple calls happen
+ return
+
+ sig_info['widget_info'] = info
+ row = sig_info['row']
+
+ # Remove the 'loading...' animation if it's there
+ item = self.itemAtPosition(row, self.COL_SETPOINT)
+ if item:
+ val_widget = item.widget()
+ if isinstance(val_widget, utils.TyphosLoading):
+ self.removeItem(item)
+ val_widget.deleteLater()
+
+ widgets = [None]
+ if info.read_cls is not None:
+ widgets.append(info.read_cls(**info.read_kwargs))
+
+ if info.write_cls is not None:
+ widgets.append(info.write_cls(**info.write_kwargs))
+
+ self._update_row(row, widgets)
+
+ visible = sig_info['visible']
+ for widget in widgets[1:]:
+ widget.setVisible(visible)
+
+ signal_pairs = list(self.signal_name_to_info.items())
+ if all(sig_info['widget_info'] is not None
+ for _, sig_info in signal_pairs):
+ self.loading_complete.emit([name for name, _ in signal_pairs])
+
+ def _create_row_label(self, attr, dotted_name, tooltip):
+ """Create a row label (i.e., the one used to display the name)."""
+ label_text = self.label_text_from_attribute(attr, dotted_name)
+ label = SignalPanelRowLabel(label_text)
+ label.setObjectName(dotted_name)
+ if tooltip is not None:
+ label.setToolTip(tooltip)
+ return label
+
+
+[docs]
+ def add_signal(self, signal, name=None, *, tooltip=None):
+ """
+ Add a signal to the panel.
+
+ The type of widget control that is drawn is dependent on
+ :attr:`_read_pv`, and :attr:`_write_pv`. attributes.
+
+ If widget information for the given signal is available in the global
+ cache, the widgets will be created immediately. Otherwise, a row will
+ be reserved and widgets created upon signal connection and background
+ description callback.
+
+ Parameters
+ ----------
+ signal : EpicsSignal, EpicsSignalRO
+ Signal to create a widget.
+
+ name : str, optional
+ The name to be used for the row label. This defaults to
+ ``signal.name``.
+
+ Returns
+ -------
+ row : int
+ Row number that the signal information was added to in the
+ `SignalPanel.layout()``.
+ """
+ name = name or signal.name
+ if signal.name in self.signal_name_to_info:
+ return
+
+ logger.debug("Adding signal %s (%s)", signal.name, name)
+
+ label = self._create_row_label(name, name, tooltip)
+ loading = utils.TyphosLoading(
+ timeout_message='Connection timed out.'
+ )
+
+ loading_tooltip = ['Connecting to:'] + list({
+ getattr(signal, attr)
+ for attr in ('setpoint_pvname', 'pvname') if hasattr(signal, attr)
+ })
+ loading.setToolTip('\n'.join(loading_tooltip))
+
+ row = self.add_row(label, loading)
+ self.signal_name_to_info[signal.name] = dict(
+ row=row,
+ signal=signal,
+ component=None,
+ widget_info=None,
+ create_signal=None,
+ visible=True,
+ )
+
+ self._connect_signal(signal)
+ return row
+
+
+ def _connect_signal(self, signal):
+ """Instantiate widgets for the given signal using the global cache."""
+ monitor = get_global_widget_type_cache()
+ item = monitor.get(signal)
+ if item is not None:
+ self._got_signal_widget_info(signal, item)
+ # else: - this will happen during a callback
+
+ def _add_component(self, device, attr, dotted_name, component):
+ """
+ Add a component which may be instantiated later.
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ The parent device for the component.
+
+ attr : str
+ The attribute name of the component.
+
+ dotted_name : str
+ The full dotted name of the component.
+
+ component : ophyd.Component
+ The component itself.
+ """
+ if dotted_name in self.signal_name_to_info:
+ return
+
+ logger.debug("Adding component %s", dotted_name)
+
+ label = self._create_row_label(
+ attr, dotted_name, tooltip=component.doc or '')
+ row = self.add_row(label, None) # utils.TyphosLoading())
+ self.signal_name_to_info[dotted_name] = dict(
+ row=row,
+ signal=None,
+ widget_info=None,
+ component=component,
+ create_signal=functools.partial(getattr, device, dotted_name),
+ visible=False,
+ )
+
+ return row
+
+
+[docs]
+ def label_text_from_attribute(self, attr, dotted_name):
+ """
+ Get label text for a given attribute.
+
+ For a basic signal panel, use the full dotted name. This is because
+ this panel flattens the device hierarchy, and using only the last
+ attribute name may lead to ambiguity or name clashes.
+ """
+ return dotted_name
+
+
+
+[docs]
+ def add_row(self, *widgets, **kwargs):
+ """
+ Add ``widgets`` to the next row.
+
+ If fewer than ``NUM_COLS`` widgets are given, the last widget will be
+ adjusted automatically to span the remaining columns.
+
+ Parameters
+ ----------
+ *widgets
+ List of :class:`QtWidgets.QWidget`.
+
+ Returns
+ -------
+ row : int
+ The row number.
+ """
+ row = self._row_count
+ self._row_count += 1
+
+ if widgets:
+ self._update_row(row, widgets, **kwargs)
+
+ return row
+
+
+ def _update_row(self, row, widgets, **kwargs):
+ """
+ Update ``row`` to contain ``widgets``.
+
+ If fewer widgets than ``NUM_COLS`` are given, the last widget will be
+ adjusted automatically to span the remaining columns.
+
+ Parameters
+ ----------
+ row : int
+ The row number.
+
+ widgets : list of :class:`QtWidgets.QWidget`
+ If ``None`` is found, the cell will be skipped.
+
+ **kwargs
+ Passed into ``addWidget``.
+ """
+ for col, item in enumerate(widgets[:-1]):
+ if item is not None:
+ self.addWidget(item, row, col, **kwargs)
+
+ last_widget = widgets[-1]
+ if last_widget is not None:
+ # Column-span the last widget over the remaining columns:
+ last_column = len(widgets) - 1
+ colspan = self.NUM_COLS - last_column
+ self.addWidget(last_widget, row, last_column, 1, colspan, **kwargs)
+
+
+[docs]
+ def add_pv(self, read_pv, name, write_pv=None):
+ """
+ Add a row, given PV names.
+
+ Parameters
+ ---------
+ read_pv : str
+ The readback PV name.
+
+ name : str
+ Name of signal to display.
+
+ write_pv : str, optional
+ The setpoint PV name.
+
+ Returns
+ -------
+ row : int
+ Row number that the signal information was added to in the
+ `SignalPanel.layout()``.
+ """
+ logger.debug("Adding PV %s", name)
+ # Configure optional write PV settings
+ if write_pv:
+ sig = EpicsSignal(read_pv, name=name, write_pv=write_pv)
+ else:
+ sig = EpicsSignalRO(read_pv, name=name)
+ return self.add_signal(sig, name)
+
+
+ @staticmethod
+ def _apply_name_filter(filter_by, *items):
+ """
+ Apply the name filter.
+
+ Parameters
+ ----------
+ filter_by : str
+ The name filter text.
+
+ *items
+ A list of strings to check for matches with.
+ """
+ if not filter_by:
+ return True
+
+ return any(filter_by in item for item in items)
+
+ def _should_show(
+ self,
+ kind: ophyd.Kind,
+ name: str,
+ *,
+ kinds: list[ophyd.Kind],
+ name_filter: Optional[str] = None,
+ show_names: Optional[list[str]] = None,
+ omit_names: Optional[list[str]] = None,
+ ):
+ """
+ Based on the filter settings, indicate if ``signal`` should be shown.
+
+ Parameters
+ ----------
+ kind : ophyd.Kind
+ The kind of the signal.
+
+ name : str
+ The name of the signal.
+
+ kinds : list of :class:`ophyd.Kind`
+ Kinds that should be shown.
+
+ name_filter : str, optional
+ Name filter text - show only signals that match this string. This
+ is applied after the "omit_names" and "show_names" filters.
+
+ show_names : list of str, optinoal
+ Names to explicitly show. Applied before the omit filter.
+
+ omit_names : list of str, optinoal
+ Names to explicitly omit.
+
+ Returns
+ -------
+ should_show : bool
+ """
+ kind = Kind(kind)
+ if kind not in kinds:
+ return False
+ for show_name in (show_names or []):
+ if show_name and show_name in name:
+ return True
+ for omit_name in (omit_names or []):
+ if omit_name and omit_name in name:
+ return False
+ return self._apply_name_filter(name_filter, name)
+
+ def _set_visible(self, signal_name, visible):
+ """
+ Change the visibility of ``signal_name`` to ``visible``.
+
+ Parameters
+ ----------
+ signal_name : str
+ The signal name to change the visibility of.
+
+ visible : bool
+ Change the visibility of the row to this.
+ """
+ info = self.signal_name_to_info[signal_name]
+ info['visible'] = bool(visible)
+ row = info['row']
+ for col in range(self.NUM_COLS):
+ item = self.itemAtPosition(row, col)
+ if item:
+ widget = item.widget()
+ if widget is not None:
+ widget.setVisible(visible)
+
+ if not visible or info['signal'] is not None:
+ return
+
+ # Create the signal if we're displaying it for the first time.
+ create_func = info['create_signal']
+ if create_func is None:
+ # A signal we shouldn't try to create again
+ return
+
+ try:
+ info['signal'] = signal = create_func()
+ except Exception as ex:
+ logger.exception('Failed to create signal %s: %s', signal_name, ex)
+ # Stop it from another attempt
+ info['create_signal'] = None
+ return
+
+ logger.debug('Instantiating a not-yet-created signal from a '
+ 'component: %s', signal.name)
+ if signal.name != signal_name:
+ # This is, for better or worse, possible; does not support the case
+ # of changing the name after __init__
+ self.signal_name_to_info[signal.name] = info
+ del self.signal_name_to_info[signal_name]
+ self._connect_signal(signal)
+
+
+[docs]
+ def filter_signals(
+ self,
+ kinds: list[ophyd.Kind],
+ name_filter: Optional[str] = None,
+ show_names: Optional[list[str]] = None,
+ omit_names: Optional[list[str]] = None,
+ ):
+ """
+ Filter signals based on the given kinds.
+
+ Parameters
+ ----------
+ kinds : list of :class:`ophyd.Kind`
+ List of kinds to show.
+
+ name_filter : str, optional
+ Name filter text - show only signals that match this string. This
+ is applied after the "omit_names" and "show_names" filters.
+
+ show_names : list of str, optinoal
+ Names to explicitly show. Applied before the omit filter.
+
+ omit_names : list of str, optinoal
+ Names to explicitly omit.
+ """
+ for name, info in list(self.signal_name_to_info.items()):
+ item = info['signal'] or info['component']
+ visible = self._should_show(
+ item.kind,
+ name,
+ kinds=kinds,
+ name_filter=name_filter,
+ omit_names=omit_names,
+ show_names=show_names,
+ )
+ self._set_visible(name, visible)
+
+ self.update()
+
+ # utils.dump_grid_layout(self)
+
+ @property
+ def _filter_settings(self):
+ """Get the current filter settings from the owner widget."""
+ return self.parent().filter_settings
+
+
+[docs]
+ def add_device(self, device):
+ """Typhos hook for adding a new device."""
+ self.clear()
+ self._devices.append(device)
+
+ sorter = _get_component_sorter(self.parent().sortBy)
+ non_devices = [
+ walk
+ for walk in sorted(device.walk_components(), key=sorter)
+ if not issubclass(walk.item.cls, ophyd.Device)
+ ]
+
+ for walk in non_devices:
+ self._maybe_add_signal(device, walk.item.attr, walk.dotted_name,
+ walk.item)
+
+ self.setSizeConstraint(self.SetMinimumSize)
+
+
+ def _maybe_add_signal(self, device, attr, dotted_name, component):
+ """
+ With the filter settings, add either the signal or a component stub.
+
+ If the component does not match the current filter settings, a
+ stub will be added that can be filled in later should the filter
+ settings change.
+
+ If the component matches the current filter settings, it will be
+ instantiated and widgets will be added when the signal is connected.
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ The device owner.
+
+ attr : str
+ The signal's attribute name.
+
+ dotted_name : str
+ The signal's dotted name.
+
+ component : ophyd.Component
+ The component class used to generate the instance.
+ """
+ if component.lazy:
+ kind = component.kind
+ else:
+ try:
+ signal = getattr(device, dotted_name)
+ except Exception as ex:
+ logger.warning('Failed to get signal %r from device %s: %s',
+ dotted_name, device.name, ex, exc_info=True)
+ return
+
+ kind = signal.kind
+
+ if self._should_show(kind, dotted_name, **self._filter_settings):
+ try:
+ with ophyd.do_not_wait_for_lazy_connection(device):
+ signal = getattr(device, dotted_name)
+ except Exception as ex:
+ logger.warning('Failed to get signal %r from device %s: %s',
+ dotted_name, device.name, ex, exc_info=True)
+ return
+
+ return self.add_signal(signal, name=attr, tooltip=component.doc)
+
+ return self._add_component(device, attr, dotted_name, component)
+
+
+[docs]
+ def clear(self):
+ """Clear the SignalPanel."""
+ logger.debug("Clearing layout %r ...", self)
+ utils.clear_layout(self)
+ self._devices.clear()
+ self.signal_name_to_info.clear()
+
+
+
+
+
+[docs]
+class TyphosSignalPanel(TyphosBase, TyphosDesignerMixin, SignalOrder):
+ """
+ Panel of Signals for a given device, using :class:`SignalPanel`.
+
+ Parameters
+ ----------
+ parent : QtWidgets.QWidget, optional
+ The parent widget.
+
+ init_channel : str, optional
+ The PyDM channel with which to initialize the widget.
+ """
+
+ Q_ENUMS(SignalOrder) # Necessary for display in Designer
+ SignalOrder = SignalOrder # For convenience
+ _kinds: Dict[str, Kind]
+ # From top of page to bottom
+ kind_order = (Kind.hinted, Kind.normal,
+ Kind.config, Kind.omitted)
+ _panel_class = SignalPanel
+ updated = QtCore.Signal()
+
+ _kind_to_property = {
+ 'hinted': 'showHints',
+ 'normal': 'showNormal',
+ 'config': 'showConfig',
+ 'omitted': 'showOmitted',
+ }
+
+ def __init__(self, parent=None, init_channel=None):
+ super().__init__(parent=parent)
+ # Create a SignalPanel layout to be modified later
+ self._panel_layout = self._panel_class()
+ self.setLayout(self._panel_layout)
+ self._name_filter = ''
+ self._show_names = []
+ self._omit_names = []
+ # Add default Kind values
+ self._kinds = {
+ "normal": True,
+ "hinted": True,
+ "config": True,
+ "omitted": True,
+ }
+ self._signal_order = SignalOrder.byKind
+
+ self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu)
+ self.contextMenuEvent = self.open_context_menu
+
+ self.nested_panel = False
+
+ def _get_kind(self, kind: str) -> ophyd.Kind:
+ """Property getter for show[kind]."""
+ return self._kinds[kind]
+
+ def _set_kind(self, value: bool, kind: str) -> None:
+ """Property setter for show[kind] = value."""
+ # If we have a new value store it
+ if value != self._kinds[kind]:
+ # Store it internally
+ self._kinds[kind] = value
+ # Remodify the layout for the new Kind
+ self._update_panel()
+
+ @property
+ def filter_settings(self):
+ """Get the filter settings dictionary."""
+ return dict(
+ name_filter=self.nameFilter,
+ omit_names=self.omitNames,
+ show_names=self.showNames,
+ kinds=self.show_kinds,
+ )
+
+ def _update_panel(self):
+ """Apply filters and emit the update signal."""
+ self._panel_layout.filter_signals(**self.filter_settings)
+ self.updated.emit()
+
+ @property
+ def show_kinds(self) -> List[Kind]:
+ """Get a list of the :class:`ophyd.Kind` that should be shown."""
+ return [Kind[kind] for kind, show in self._kinds.items() if show]
+
+ # Kind Configuration pyqtProperty
+ showHints = Property(bool,
+ partial(_get_kind, kind='hinted'),
+ partial(_set_kind, kind='hinted'),
+ doc='Show ophyd.Kind.hinted signals')
+ showNormal = Property(bool,
+ partial(_get_kind, kind='normal'),
+ partial(_set_kind, kind='normal'),
+ doc='Show ophyd.Kind.normal signals')
+ showConfig = Property(bool,
+ partial(_get_kind, kind='config'),
+ partial(_set_kind, kind='config'),
+ doc='Show ophyd.Kind.config signals')
+ showOmitted = Property(bool,
+ partial(_get_kind, kind='omitted'),
+ partial(_set_kind, kind='omitted'),
+ doc='Show ophyd.Kind.omitted signals')
+
+ @Property(str)
+ def nameFilter(self) -> str:
+ """Get or set the current name filter."""
+ return self._name_filter
+
+ @nameFilter.setter
+ def nameFilter(self, name_filter: str):
+ if name_filter != self._name_filter:
+ self._name_filter = name_filter.strip()
+ self._update_panel()
+
+ @Property("QStringList")
+ def omitNames(self) -> list[str]:
+ """Get or set the list of names to omit."""
+ return self._omit_names
+
+ @omitNames.setter
+ def omitNames(self, omit_names: Optional[list[str]]) -> None:
+ if omit_names != self._omit_names:
+ self._omit_names = list(omit_names or [])
+ self._update_panel()
+
+ @Property("QStringList")
+ def showNames(self) -> list[str]:
+ """Get or set the list of names to omit."""
+ return self._show_names
+
+ @showNames.setter
+ def showNames(self, show_names: Optional[list[str]]) -> None:
+ if show_names != self._show_names:
+ self._show_names = list(show_names or [])
+ self._update_panel()
+
+ @Property(SignalOrder)
+ def sortBy(self):
+ """Get or set the order that the signals will be placed in layout."""
+ return self._signal_order
+
+ @sortBy.setter
+ def sortBy(self, value):
+ if value != self._signal_order:
+ self._signal_order = value
+ self._update_panel()
+
+
+[docs]
+ def add_device(self, device):
+ """Typhos hook for adding a new device."""
+ self.devices.clear()
+ self.nested_panel = False
+ super().add_device(device)
+ # Configure the layout for the new device
+ self._panel_layout.add_device(device)
+ self._update_panel()
+ parent = self.parent()
+ while parent is not None:
+ if isinstance(parent, TyphosSignalPanel):
+ self.nested_panel = True
+ break
+ parent = parent.parent()
+
+
+
+[docs]
+ def set_device_display(self, display):
+ """Typhos hook for when the TyphosDeviceDisplay is associated."""
+ self.display = display
+
+
+
+
+
+
+
+
+ def maybe_fix_parent_size(self):
+ if self.nested_panel:
+ # force this widget's containers to give it enough space!
+ self.parent().setMinimumHeight(self.parent().minimumSizeHint().height())
+
+
+[docs]
+ def resizeEvent(self, event: QtGui.QResizeEvent):
+ """
+ Fix the parent container's size whenever our size changes.
+
+ This also runs when we add or filter rows.
+ """
+ self.maybe_fix_parent_size()
+ return super().resizeEvent(event)
+
+
+
+[docs]
+ def setVisible(self, visible: bool):
+ """
+ Fix the parent container's size whenever we switch visibility.
+
+ This also runs when we toggle a row visibility using the title
+ and when all signal rows get filtered all at once.
+ """
+ rval = super().setVisible(visible)
+ self.maybe_fix_parent_size()
+ return rval
+
+
+
+
+
+[docs]
+class CompositeSignalPanel(SignalPanel):
+ """
+ Composite panel layout for :class:`ophyd.Signal` and other ophyd objects.
+
+ Contrasted to :class:`SignalPanel`, this class retains the hierarchy built
+ into an :class:`ophyd.Device` hierarchy. Individual signals mix in with
+ sub-device displays, which may or may not have custom screens.
+
+ Attributes
+ ----------
+ loading_complete : QtCore.Signal
+ A signal indicating that loading of the panel has completed.
+
+ NUM_COLS : int
+ The number of columns in the layout.
+
+ COL_LABEL : int
+ The column number for the row label.
+
+ COL_READBACK : int
+ The column number for the readback widget.
+
+ COL_SETPOINT : int
+ The column number for the setpoint widget.
+ """
+
+ _qt_designer_ = {
+ "group": "Typhos Widgets",
+ "is_container": False,
+ }
+
+ def __init__(self):
+ super().__init__(signals=None)
+ self._containers = {}
+
+
+[docs]
+ def label_text_from_attribute(self, attr, dotted_name):
+ """Get label text for a given attribute."""
+ # For a hierarchical signal panel, use only the attribute name.
+ return attr
+
+
+
+[docs]
+ def add_sub_device(self, device, name):
+ """
+ Add a sub-device to the next row.
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ The device to add.
+
+ name : str
+ The name/label to go with the device.
+ """
+ logger.debug('%s adding sub-device: %s (%s)', self.__class__.__name__,
+ device.name, device.__class__.__name__)
+ container = display.TyphosDeviceDisplay(
+ scrollable=False,
+ nested=True,
+ )
+ self._containers[name] = container
+ self.add_row(container)
+ container.add_device(device)
+
+
+
+[docs]
+ def add_device(self, device):
+ """Typhos hook for adding a new device."""
+ # TODO: note that this does not call super
+ # super().add_device(device)
+ self._devices.append(device)
+
+ logger.debug('%s signals from device: %s', self.__class__.__name__,
+ device.name)
+
+ for attr, component in utils._get_top_level_components(type(device)):
+ dotted_name = f'{device.name}.{attr}'
+ if issubclass(component.cls, ophyd.Device):
+ sub_device = getattr(device, attr)
+ self.add_sub_device(sub_device, name=dotted_name)
+ else:
+ self._maybe_add_signal(device, attr, attr, component)
+
+
+ @property
+ def visible_elements(self):
+ """Return all visible signals and components."""
+ sigs = self.visible_signals
+ containers = {
+ name: cont
+ for name, cont in self._containers.items() if cont.isVisible()
+ }
+ sigs.update(containers)
+ return sigs
+
+
+
+
+[docs]
+class TyphosCompositeSignalPanel(TyphosSignalPanel):
+ """
+ Hierarchical panel for a device, using :class:`CompositeSignalPanel`.
+
+ Parameters
+ ----------
+ parent : QtWidgets.QWidget, optional
+ The parent widget.
+
+ init_channel : str, optional
+ The PyDM channel with which to initialize the widget.
+ """
+
+ _panel_class = CompositeSignalPanel
+
+
+"""
+Module Docstring
+"""
+import logging
+
+import numpy as np
+from ophyd import Signal
+from ophyd.utils.epics_pvs import AlarmSeverity, _type_map
+from pydm.data_plugins.plugin import PyDMConnection, PyDMPlugin
+from qtpy.QtCore import Qt, Slot
+
+from ..utils import raise_to_operator
+
+logger = logging.getLogger(__name__)
+
+signal_registry = dict()
+
+
+
+[docs]
+def register_signal(signal):
+ """
+ Add a new Signal to the registry.
+
+ The Signal object is kept within ``signal_registry`` for reference by name
+ in the :class:`.SignalConnection`. Signals can be added multiple times,
+ but only the first register_signal call for each unique signal name
+ has any effect.
+
+ Signals can be referenced by their ``name`` attribute or by their
+ full dotted path starting from the parent's name.
+ """
+ # Pick all the name aliases (name, dotted path)
+ if signal is signal.root:
+ names = (signal.name,)
+ else:
+ # .dotted_name does not include the root device's name
+ names = (
+ signal.name,
+ '.'.join((signal.root.name, signal.dotted_name)),
+ )
+ # Warn the user if they are adding twice
+ for name in names:
+ if name in signal_registry:
+ # Case 1: harmless re-add
+ if signal_registry[name] is signal:
+ logger.debug(
+ "The signal named %s is already registered!",
+ name,
+ )
+ # Case 2: harmful overwrite! Name collision!
+ else:
+ logger.warning(
+ "A different signal named %s is already registered!",
+ name,
+ )
+ return
+ logger.debug("Registering signal with names %s", names)
+ for name in names:
+ signal_registry[name] = signal
+
+
+
+
+[docs]
+class SignalConnection(PyDMConnection):
+ """
+ Connection to monitor an Ophyd Signal.
+
+ This is meant as a generalized connection to any type of Ophyd Signal. It
+ handles reporting updates to listeners as well as pushing new values that
+ users request in the PyDM interface back to the underlying signal.
+
+ The signal `data_type` is used to inform PyDM on the Python type that the
+ signal will expect and emit. It is expected that this type is static
+ through the execution of the application.
+
+ Attributes
+ ----------
+ signal : ophyd.Signal
+ Stored signal object.
+ """
+ supported_types = [int, float, str, np.ndarray]
+
+ def __init__(self, channel, address, protocol=None, parent=None):
+ # Create base connection
+ super().__init__(channel, address, protocol=protocol, parent=parent)
+ self._connection_open: bool = True
+ self.signal_type: type | None = None
+ self.is_float: bool = False
+ self.enum_strs: tuple[str, ...] = ()
+
+ # Collect our signal
+ self.signal = self.find_signal(address)
+ # Subscribe to updates from Ophyd
+ self.value_cid = self.signal.subscribe(
+ self.send_new_value,
+ event_type=self.signal.SUB_VALUE,
+ )
+ self.meta_cid = self.signal.subscribe(
+ self.send_new_meta,
+ event_type=self.signal.SUB_META,
+ )
+ # Add listener
+ self.add_listener(channel)
+
+ def __dtor__(self) -> None:
+ self._connection_open = False
+ self.close()
+
+
+[docs]
+ def find_signal(self, address: str) -> Signal:
+ """Find a signal in the registry given its address.
+
+ This method is intended to be overridden by subclasses that
+ may use a different mechanism to keep track of signals.
+
+ Parameters
+ ----------
+ address
+ The connection address for the signal. E.g. in
+ "sig://sim_motor.user_readback" this would be the
+ "sim_motor.user_readback" portion.
+
+ Returns
+ -------
+ Signal
+ The Ophyd signal corresponding to the address.
+
+ """
+ return signal_registry[address]
+
+
+
+[docs]
+ def cast(self, value):
+ """
+ Cast a value to the correct Python type based on ``signal_type``.
+
+ If ``signal_type`` is not set, the result of ``ophyd.Signal.describe``
+ is used to determine what the correct Python type for value is. We need
+ to be aware of the correct Python type so that we can emit the value
+ through the correct signal and convert values returned by the widget to
+ the correct type before handing them to Ophyd Signal.
+ """
+ # If this is the first time we are receiving a new value note the type
+ # We make the assumption that signals do not change types during a
+ # connection
+ if not self.signal_type:
+ dtype = self.signal.describe()[self.signal.name]['dtype']
+ # Only way this raises a KeyError is if ophyd is confused
+ self.signal_type = _type_map[dtype][0]
+ logger.debug("Found signal type %r for %r. Using Python type %r",
+ dtype, self.signal.name, self.signal_type)
+
+ logger.debug("Casting %r to %r", value, self.signal_type)
+ if self.enum_strs:
+ # signal_type is either int or str
+ # use enums to cast type
+ if self.signal_type is int:
+ # Get the index
+ try:
+ value = self.enum_strs.index(value)
+ except (TypeError, ValueError, AttributeError):
+ value = int(value)
+ elif self.signal_type is str:
+ # Get the enum string
+ try:
+ value = self.enum_strs[value]
+ except (TypeError, ValueError):
+ value = str(value)
+ else:
+ raise TypeError(
+ f"Invalid combination: enum_strs={self.enum_strs} with signal_type={self.signal_type}"
+ )
+ elif self.signal_type is np.ndarray:
+ value = np.asarray(value)
+ else:
+ value = self.signal_type(value)
+ return value
+
+
+
+[docs]
+ @Slot(int)
+ @Slot(float)
+ @Slot(str)
+ @Slot(np.ndarray)
+ def put_value(self, new_val):
+ """
+ Pass a value from the UI to Signal.
+
+ We are not guaranteed that this signal is writeable so catch exceptions
+ if they are created. We attempt to cast the received value into the
+ reported type of the signal unless it is of type ``np.ndarray``.
+ """
+ try:
+ new_val = self.cast(new_val)
+ logger.debug("Putting value %r to %r", new_val, self.address)
+ self.signal.put(new_val)
+ except Exception as exc:
+ logger.exception("Unable to put %r to %s", new_val, self.address)
+ raise_to_operator(exc)
+
+
+
+[docs]
+ def send_new_value(self, value=None, **kwargs):
+ """
+ Update the UI with a new value from the Signal.
+ """
+ if not self._connection_open:
+ return
+
+ try:
+ value = self.cast(value)
+ self.new_value_signal[self.signal_type].emit(value)
+ except Exception:
+ logger.exception("Unable to update %r with value %r.",
+ self.signal.name, value)
+
+
+
+[docs]
+ def send_new_meta(
+ self,
+ connected=None,
+ write_access=None,
+ severity=None,
+ precision=None,
+ units=None,
+ enum_strs=None,
+ **kwargs
+ ):
+ """
+ Update the UI with new metadata from the Signal.
+
+ Signal metadata updates always send all available metadata, so
+ default values to this function will not be sent ever if the signal
+ has valid data there.
+
+ We default missing metadata to None and skip emitting in general,
+ but for severity we default to NO_ALARM for UI purposes. We don't
+ want the UI to assume that anything is in an alarm state.
+ """
+ if not self._connection_open:
+ return
+
+ # Only emit the non-None values
+ if connected is not None:
+ self.connection_state_signal.emit(connected)
+ if write_access is not None:
+ self.write_access_signal.emit(write_access)
+ if precision is not None:
+ if precision <= 0:
+ # Help the user a bit by replacing a clear design error
+ # with a sensible default
+ if self.is_float:
+ # Float precision at 0 is unhelpful
+ precision = 3
+ else:
+ # Integer precision can't be negative
+ precision = 0
+ self.prec_signal.emit(precision)
+ if units is not None:
+ self.unit_signal.emit(units)
+ if enum_strs is not None:
+ self.enum_strings_signal.emit(enum_strs)
+ self.enum_strs = enum_strs
+
+ # Special handling for severity
+ if severity is None:
+ severity = AlarmSeverity.NO_ALARM
+ self.new_severity_signal.emit(severity)
+
+
+
+[docs]
+ def add_listener(self, channel):
+ """
+ Add a listener channel to this connection.
+
+ This attaches values input by the user to the `send_new_value` function
+ in order to update the Signal object in addition to the default setup
+ performed in PyDMConnection.
+ """
+ # Perform the default connection setup
+ logger.debug("Adding %r ...", channel)
+ super().add_listener(channel)
+ try:
+ # Gather the current value
+ signal_val = self.signal.get()
+ # Gather metadata
+ signal_meta = self.signal.metadata
+ except Exception:
+ logger.exception("Failed to gather proper information "
+ "from signal %r to initialize %r",
+ self.signal.name, channel)
+ return
+ if isinstance(signal_val, (float, np.floating)):
+ # Precision is commonly omitted from non-epics signals
+ # Pick a sensible default for displaying floats
+ self.is_float = True
+ # precision might be missing entirely
+ signal_meta.setdefault("precision", 3)
+ # precision might be None, which is code for unset
+ if signal_meta["precision"] is None:
+ signal_meta["precision"] = 3
+ else:
+ self.is_float = False
+
+ # Report new meta for context, then value
+ self.send_new_meta(**signal_meta)
+ self.send_new_value(signal_val)
+ # If the channel is used for writing to PVs, hook it up to the
+ # 'put' methods.
+ if channel.value_signal is not None:
+ for _typ in self.supported_types:
+ try:
+ val_sig = channel.value_signal[_typ]
+ val_sig.connect(self.put_value, Qt.QueuedConnection)
+ except KeyError:
+ logger.debug("%s has no value_signal for type %s",
+ channel.address, _typ)
+
+
+
+[docs]
+ def remove_listener(self, channel, destroying=False, **kwargs):
+ """
+ Remove a listener channel from this connection.
+
+ This removes the `send_new_value` connections from the channel in
+ addition to the default disconnection performed in PyDMConnection.
+ """
+ logger.debug("Removing %r ...", channel)
+ # Disconnect put_value from outgoing channel
+ if channel.value_signal is not None and not destroying:
+ for _typ in self.supported_types:
+ try:
+ channel.value_signal[_typ].disconnect(self.put_value)
+ except (KeyError, TypeError):
+ logger.debug("Unable to disconnect value_signal from %s "
+ "for type %s", channel.address, _typ)
+ # Disconnect any other signals
+ super().remove_listener(channel, destroying=destroying, **kwargs)
+ logger.debug("Successfully removed %r", channel)
+
+
+
+[docs]
+ def close(self):
+ """Unsubscribe from the Ophyd signal."""
+ self.signal.unsubscribe(self.value_cid)
+ self.signal.unsubscribe(self.meta_cid)
+
+
+
+
+
+[docs]
+class SignalPlugin(PyDMPlugin):
+ """Plugin registered with PyDM to handle SignalConnection."""
+ protocol = 'sig'
+ connection_class = SignalConnection
+
+ def add_connection(self, channel):
+ """Add a connection to a channel."""
+ try:
+ # Add a PyDMConnection for the channel
+ super().add_connection(channel)
+ # There is a chance that we raise an Exception on creation. If so,
+ # don't add this to our list of good to go connections. The next
+ # attempt we try again.
+ except KeyError:
+ logger.error("Unable to find signal for %r in signal registry."
+ "Use typhos.plugins.register_signal()",
+ channel)
+ except Exception:
+ logger.exception("Unable to create a connection to %r",
+ channel)
+
+ def remove_connection(self, channel, destroying=False):
+ try:
+ return super().remove_connection(channel, destroying=destroying)
+ except RuntimeError as ex:
+ # deleteLater() at teardown can raise; let's silence that
+ if not str(ex).endswith("has been deleted"):
+ raise
+
+ with self.lock:
+ self.connections.pop(self.get_connection_id(channel), None)
+
+
+import logging
+
+from happi import Client
+from happi.errors import SearchError
+from happi.loader import from_container
+from pydm.data_plugins.plugin import PyDMConnection, PyDMPlugin
+from qtpy import QtCore
+
+
+class HappiClientState:
+ client = None
+
+
+logger = logging.getLogger(__name__)
+
+
+
+[docs]
+def register_client(client):
+ """
+ Register a Happi Client to be used with the DataPlugin.
+
+ This is not required to be called by the user, if your environment is setup
+ such that :meth:`happi.Client.from_config` will return the desired client.
+ """
+ HappiClientState.client = client
+
+
+
+
+[docs]
+class HappiConnection(PyDMConnection):
+ """A PyDMConnection to the Happi Database."""
+ tx = QtCore.Signal(dict)
+
+ def __init__(self, channel, address, protocol=None, parent=None):
+ super().__init__(channel, address, protocol=protocol, parent=parent)
+ self.add_listener(channel)
+
+
+[docs]
+ def add_listener(self, channel):
+ """Add a new channel to the existing connection."""
+ super().add_listener(channel)
+ # Connect our channel to the signal
+ self.tx.connect(channel.tx_slot, QtCore.Qt.QueuedConnection)
+ logger.debug("Loading %r from happi Client", channel)
+ if '.' in self.address:
+ device, child = self.address.split('.', 1)
+ else:
+ device, child = self.address, None
+ # Load the device from the Client
+ md = HappiClientState.client.find_item(name=device)
+ obj = from_container(md)
+ md = md.post()
+ # If we have a child grab it
+ if child:
+ logger.debug("Retrieving child %r from %r",
+ child, obj.name)
+ obj = getattr(obj, child)
+ md = {'name': obj.name}
+ # Send the device and metdata to all of our subscribers
+ self.tx.emit({'obj': obj, 'md': md})
+
+
+
+[docs]
+ def remove_listener(self, channel, destroying=False, **kwargs):
+ """Remove a channel from the database connection."""
+ super().remove_listener(channel, destroying=destroying, **kwargs)
+ if not destroying:
+ self.tx.disconnect(channel.tx_slot)
+
+
+
+
+
+[docs]
+class HappiPlugin(PyDMPlugin):
+ protocol = 'happi'
+ connection_class = HappiConnection
+
+ def add_connection(self, channel):
+ # If we haven't made a Client by the time we need the Plugin. Try
+ # and load one from configuration file
+ if not HappiClientState.client:
+ register_client(Client.from_config())
+ try:
+ super().add_connection(channel)
+ except SearchError:
+ logger.error("Unable to find device for %r in happi database.",
+ channel)
+ except AttributeError as exc:
+ logger.exception("Invalid attribute %r for address %r",
+ exc, channel.address)
+ except Exception:
+ logger.exception("Unable to load %r from happi", channel.address)
+
+
+from __future__ import annotations
+
+import inspect
+import logging
+import math
+import os.path
+import threading
+import typing
+from typing import Optional, Union
+
+import ophyd
+from pydm.widgets.channel import PyDMChannel
+from qtpy import QtCore, QtGui, QtWidgets, uic
+
+from typhos.display import TyphosDisplaySwitcher
+
+from . import dynamic_font, utils, widgets
+from .alarm import AlarmLevel, KindLevel, _KindLevel
+from .panel import SignalOrder, TyphosSignalPanel
+from .status import TyphosStatusMessage, TyphosStatusResult, TyphosStatusThread
+
+logger = logging.getLogger(__name__)
+
+if typing.TYPE_CHECKING:
+ import pydm.widgets
+
+ from .alarm import TyphosAlarmRectangle
+ from .notes import TyphosNotesEdit
+ from .related_display import TyphosRelatedSuiteButton
+
+
+class _TyphosPositionerUI(QtWidgets.QWidget):
+ """Annotations helper for positioner.ui; not to be instantiated."""
+
+ alarm_circle: TyphosAlarmRectangle
+ alarm_label: QtWidgets.QLabel
+ alarm_layout: QtWidgets.QVBoxLayout
+ app: QtWidgets.QApplication
+ clear_error_button: QtWidgets.QPushButton
+ device_name_label: QtWidgets.QLabel
+ devices: list
+ error_label: pydm.widgets.label.PyDMLabel
+ expand_button: QtWidgets.QPushButton
+ expert_button: TyphosRelatedSuiteButton
+ high_limit: pydm.widgets.label.PyDMLabel
+ high_limit_layout: QtWidgets.QVBoxLayout
+ high_limit_switch: pydm.widgets.byte.PyDMByteIndicator
+ horizontalLayout: QtWidgets.QHBoxLayout
+ low_limit: pydm.widgets.label.PyDMLabel
+ low_limit_layout: QtWidgets.QVBoxLayout
+ low_limit_switch: pydm.widgets.byte.PyDMByteIndicator
+ moving_indicator: pydm.widgets.byte.PyDMByteIndicator
+ moving_indicator_label: QtWidgets.QLabel
+ moving_indicator_layout: QtWidgets.QVBoxLayout
+ row_frame: QtWidgets.QFrame
+ setpoint_layout: QtWidgets.QVBoxLayout
+ setpoint_outer_layout: QtWidgets.QVBoxLayout
+ status_container_widget: QtWidgets.QWidget
+ status_label: QtWidgets.QLabel
+ status_text_layout: QtWidgets.QVBoxLayout
+ stop_button: QtWidgets.QPushButton
+ tweak_layout: QtWidgets.QHBoxLayout
+ tweak_negative: QtWidgets.QToolButton
+ tweak_positive: QtWidgets.QToolButton
+ tweak_value: QtWidgets.QLineEdit
+ tweak_widget: QtWidgets.QWidget
+ user_readback: pydm.widgets.label.PyDMLabel
+ user_setpoint: pydm.widgets.line_edit.PyDMLineEdit
+
+ # Dynamically added:
+ set_value: Union[widgets.NoScrollComboBox, QtWidgets.QLineEdit]
+
+
+
+[docs]
+class TyphosPositionerWidget(
+ utils.TyphosBase,
+ widgets.TyphosDesignerMixin,
+ _KindLevel,
+):
+ """
+ Widget to interact with a :class:`ophyd.Positioner`.
+
+ Standard positioner motion requires a large amount of context for
+ operators. For most motors, it may not be enough to simply have a text
+ field where setpoints can be punched in. Instead, information like soft
+ limits and hardware limit switches are crucial for a full understanding of
+ the position and behavior of a motor. The widget will work with any object
+ that implements the method ``set``, however to get other relevant
+ information, we see if we can find other useful signals. Below is a table
+ of attributes that the widget looks for to inform screen design.
+
+ ============== ===========================================================
+ Widget Attribute Selection
+ ============== ===========================================================
+ User Readback The ``readback_attribute`` property is used, which defaults
+ to ``user_readback``. Linked to UI element
+ ``user_readback``.
+
+ User Setpoint The ``setpoint_attribute`` property is used, which defaults
+ to ``user_setpoint``. Linked to UI element
+ ``user_setpoint``.
+
+ Limit Switches The ``low_limit_switch_attribute`` and
+ ``high_limit_switch_attribute`` properties are used, which
+ default to ``low_limit_switch`` and ``high_limit_switch``,
+ respectively.
+
+ Soft Limits The ``low_limit_travel_attribute`` and
+ ``high_limit_travel_attribute`` properties are used, which
+ default to ``low_limit_travel`` and ``high_limit_travel``,
+ respectively. As a fallback, the ``limit`` property on the
+ device may be queried directly.
+
+ Set and Tweak Both of these methods simply use ``Device.set`` which is
+ expected to take a ``float`` and return a ``status`` object
+ that indicates the motion completeness. Must be implemented.
+
+ Stop ``Device.stop()``, if available, otherwise hide the button.
+ If you have a non-functional ``stop`` method inherited from
+ a parent device, you can hide it from ``typhos`` by
+ overriding it with a property that raises
+ ``AttributeError`` on access.
+
+ Move Indicator The ``moving_attribute`` property is used, which defaults
+ to ``motor_is_moving``. Linked to UI element
+ ``moving_indicator``.
+
+ Error Message The ``error_message_attribute`` property is used, which
+ defaults to ``error_message``. Linked to UI element
+ ``error_label``.
+
+ Clear Error ``Device.clear_error()``, if applicable. This also clears
+ any visible error messages from the status returned by
+ ``Device.set``.
+
+ Alarm Circle Uses the ``TyphosAlarmCircle`` widget to summarize the
+ alarm state of all of the device's ``normal`` and
+ ``hinted`` signals.
+ ============== ===========================================================
+ """
+ QtCore.Q_ENUMS(_KindLevel)
+ KindLevel = KindLevel
+
+ ui: _TyphosPositionerUI
+ ui_template = os.path.join(utils.ui_dir, 'widgets', 'positioner.ui')
+ _readback_attr = 'user_readback'
+ _setpoint_attr = 'user_setpoint'
+ _low_limit_switch_attr = 'low_limit_switch'
+ _high_limit_switch_attr = 'high_limit_switch'
+ _low_limit_travel_attr = 'low_limit_travel'
+ _high_limit_travel_attr = 'high_limit_travel'
+ _velocity_attr = 'velocity'
+ _acceleration_attr = 'acceleration'
+ _moving_attr = 'motor_is_moving'
+ _error_message_attr = 'error_message'
+ _min_visible_operation = 0.1
+
+ alarm_text = {
+ AlarmLevel.NO_ALARM: 'no alarm',
+ AlarmLevel.MINOR: 'minor',
+ AlarmLevel.MAJOR: 'major',
+ AlarmLevel.DISCONNECTED: 'no conn',
+ AlarmLevel.INVALID: 'invalid',
+ }
+
+ def __init__(self, parent=None):
+ self._moving = False
+ self._last_move = None
+ self._readback = None
+ self._setpoint = None
+ self._status_thread = None
+ self._initialized = False
+ self._moving_channel = None
+
+ self._show_lowlim = True
+ self._show_lowtrav = True
+ self._show_highlim = True
+ self._show_hightrav = True
+
+ super().__init__(parent=parent)
+
+ self.ui = typing.cast(_TyphosPositionerUI, uic.loadUi(self.ui_template, self))
+ self.ui.tweak_positive.clicked.connect(self.positive_tweak)
+ self.ui.tweak_negative.clicked.connect(self.negative_tweak)
+ self.ui.stop_button.clicked.connect(self.stop)
+ self.ui.clear_error_button.clicked.connect(self.clear_error)
+
+ self.ui.alarm_circle.kindLevel = self.ui.alarm_circle.NORMAL
+ self.ui.alarm_circle.alarm_changed.connect(self.update_alarm_text)
+
+ self.show_expert_button = False
+ self._after_set_moving(False)
+
+ dynamic_font.patch_widget(self.ui.user_readback, pad_percent=0.05, min_size=4)
+
+ def _clear_status_thread(self):
+ """Clear a previous status thread."""
+ if self._status_thread is None:
+ return
+
+ logger.debug("Clearing current active status")
+ self._status_thread.disconnect()
+ self._status_thread = None
+
+ def _start_status_thread(
+ self,
+ status: ophyd.StatusBase,
+ timeout: float,
+ timeout_desc: str,
+ ) -> None:
+ """Start the status monitoring thread for the given status object."""
+ self._status_thread = thread = TyphosStatusThread(
+ status,
+ error_context="Move",
+ timeout_calc=timeout_desc,
+ start_delay=self._min_visible_operation,
+ timeout=timeout,
+ parent=self,
+ )
+ thread.status_started.connect(self._move_started)
+ thread.status_finished.connect(self._status_finished)
+ thread.error_message.connect(self._set_status_text)
+ thread.start()
+
+ def _get_timeout(
+ self,
+ set_position: float,
+ settle_time: float,
+ rescale: float = 1,
+ ) -> tuple[int | None, str]:
+ """
+ Use positioner's configuration to select a timeout.
+
+ This will estimate the amount of time it will take to get to the
+ set_position. The calculation is simplified and is intended to be
+ slightly greater than the true expected move duration:
+
+ move_time ~= distance/velocity + accel_time + deccel_time
+ *(note: we assume accel_time = deccel_time)
+
+ Which is just the trapezoidal move curve, but a little bit longer.
+
+ The timeout will be:
+ timeout = settle_time + rescale * move_time
+
+ A return value of ``None`` will be used if we cannot determine the
+ velocity, which is interpreted by ophyd as "never times out".
+ If we can't determine the acceleration time, we will assume it is
+ zero.
+
+ Parameters
+ ----------
+ set_position : float
+ The position we'd like to move to.
+ settle_time : float
+ How long to wait on top of the calculated move time.
+ Note that this does not get ``rescale`` applied on top of it.
+ rescale : float
+ A scaling factor, multiplied onto the calculated move time.
+ This can be used to give some extra margin proportional to
+ the expected move time, e.g. for long moves.
+
+ Returns
+ -------
+ timeout : tuple[int or None, str]
+ The timeout to use for this move, or None if a timeout could
+ not be calculated, bundled with an explanation on how it
+ was calculated.
+ """
+ pos_sig = getattr(self.device, self._readback_attr, None)
+ vel_sig = getattr(self.device, self._velocity_attr, None)
+ acc_sig = getattr(self.device, self._acceleration_attr, None)
+ # Not enough info == no timeout
+ if pos_sig is None or vel_sig is None:
+ return (None, "no timeout, missing info")
+ delta = pos_sig.get() - set_position
+ speed = vel_sig.get()
+ # Bad speed == no timeout
+ if speed == 0:
+ return (None, "no timeout, speed == 0")
+ # Bad acceleration == ignore acceleration
+ if acc_sig is None:
+ acc_time = 0
+ else:
+ acc_time = acc_sig.get()
+ units = pos_sig.metadata.get("units", "egu")
+ # Some friendly names for the f-string tooltip reporting
+ dist = abs(delta)
+ speed = abs(speed)
+ mult = rescale
+ # This time is always greater than the kinematic calc
+ return (
+ math.ceil(rescale * (dist/speed + 2 * abs(acc_time)) + abs(settle_time)),
+ ("an upper bound on the expected time based on the speed, distance traveled, "
+ "and acceleration time. Numerically, this is "
+ f"{mult=}*({dist=:.2f}{units}/{speed=:.2f}{units}/s) + "
+ f"2*{acc_time=:.2f}s + {settle_time=}s, rounded up."),
+ )
+
+ def _set(self, value):
+ """Inner `set` routine - call device.set() and monitor the status."""
+ self._clear_status_thread()
+ self._last_move = None
+ if isinstance(self.ui.set_value, widgets.NoScrollComboBox):
+ set_position = value
+ else:
+ set_position = float(value)
+
+ try:
+ # Always at least 5s, give 20% extra time as margin for long moves
+ timeout, desc = self._get_timeout(set_position, settle_time=5, rescale=1.2)
+ except Exception:
+ # Something went wrong, just run without a timeout.
+ logger.exception('Unable to estimate motor timeout.')
+ timeout = None
+ logger.debug("Setting device %r to %r with timeout %r",
+ self.device, value, timeout)
+ try:
+ status = self.device.set(set_position)
+ except Exception as exc:
+ # Treat this exception as a status to use normal error reporting
+ # Usually this is e.g. limits error
+ self._status_finished(exc)
+ else:
+ # Send timeout through thread because status timeout stops the move
+ self._start_status_thread(status, timeout, desc)
+
+ @QtCore.Slot(int)
+ def combo_set(self, index):
+ self.set()
+
+
+[docs]
+ @QtCore.Slot()
+ def set(self):
+ """Set the device to the value configured by ``ui.set_value``"""
+ if not self.device:
+ return
+
+ value = None
+ try:
+ if isinstance(self.ui.set_value, widgets.NoScrollComboBox):
+ value = self.ui.set_value.currentText()
+ else:
+ value = self.ui.set_value.text()
+ self._set(value)
+ except Exception as exc:
+ logger.exception("Error setting %r to %r", self.devices, value)
+ self._last_move = False
+ utils.reload_widget_stylesheet(self, cascade=True)
+ utils.raise_to_operator(exc)
+
+
+
+[docs]
+ def tweak(self, offset):
+ """Tweak by the given ``offset``."""
+ try:
+ setpoint = self._get_position() + float(offset)
+ except Exception:
+ logger.exception('Tweak failed')
+ return
+
+ self.ui.set_value.setText(str(setpoint))
+ self.set()
+
+
+
+[docs]
+ @QtCore.Slot()
+ def positive_tweak(self):
+ """Tweak positive by the amount listed in ``ui.tweak_value``"""
+ try:
+ self.tweak(float(self.tweak_value.text()))
+ except Exception:
+ logger.exception('Tweak failed')
+
+
+
+[docs]
+ @QtCore.Slot()
+ def negative_tweak(self):
+ """Tweak negative by the amount listed in ``ui.tweak_value``"""
+ try:
+ self.tweak(-float(self.tweak_value.text()))
+ except Exception:
+ logger.exception('Tweak failed')
+
+
+
+[docs]
+ @QtCore.Slot()
+ def stop(self):
+ """Stop device"""
+ for device in self.devices:
+ # success=True means expected stop
+ device.stop(success=True)
+
+
+
+[docs]
+ @QtCore.Slot()
+ def clear_error(self):
+ """
+ Clear the error messages from the device and screen.
+
+ The device may have errors in the IOC. These will be cleared by calling
+ the clear_error method.
+
+ The screen may have errors from the status of the last move. These will
+ be cleared from view.
+ """
+ for device in self.devices:
+ clear_error_in_background(device)
+ self._set_status_text('')
+ # This variable holds True if last move was good, False otherwise
+ # It also controls whether or not we have a red box on the widget
+ # False = Red, True = Green, None = no box (in motion is yellow)
+ if not self._last_move:
+ self._last_move = None
+
+
+ def _get_position(self):
+ if not self._readback:
+ raise Exception("No Device configured for widget!")
+ return self._readback.get()
+
+ @utils.linked_attribute('readback_attribute', 'ui.user_readback', True)
+ def _link_readback(self, signal, widget):
+ """Link the positioner readback with the ui element."""
+ self._readback = signal
+
+ @utils.linked_attribute('setpoint_attribute', 'ui.user_setpoint', True)
+ def _link_setpoint(self, signal, widget):
+ """Link the positioner setpoint with the ui element."""
+ self._setpoint = signal
+ if signal is not None:
+ # Seed the set_value text with the user_setpoint channel value.
+ if hasattr(widget, 'textChanged'):
+ widget.textChanged.connect(self._user_setpoint_update)
+
+ @utils.linked_attribute('low_limit_switch_attribute',
+ 'ui.low_limit_switch', True)
+ def _link_low_limit_switch(self, signal, widget):
+ """Link the positioner lower limit switch with the ui element."""
+ if signal is None:
+ widget.hide()
+ self._show_lowlim = False
+
+ @utils.linked_attribute('high_limit_switch_attribute',
+ 'ui.high_limit_switch', True)
+ def _link_high_limit_switch(self, signal, widget):
+ """Link the positioner high limit switch with the ui element."""
+ if signal is None:
+ widget.hide()
+ self._show_highlim = False
+
+ @utils.linked_attribute('low_limit_travel_attribute', 'ui.low_limit', True)
+ def _link_low_travel(self, signal, widget):
+ """Link the positioner lower travel limit with the ui element."""
+ return signal is not None
+
+ @utils.linked_attribute('high_limit_travel_attribute', 'ui.high_limit',
+ True)
+ def _link_high_travel(self, signal, widget):
+ """Link the positioner high travel limit with the ui element."""
+ return signal is not None
+
+ def _link_limits_by_limits_attr(self):
+ """Link limits by using ``device.limits``."""
+ device = self.device
+ try:
+ low_limit, high_limit = device.limits
+ except Exception:
+ ...
+ else:
+ if low_limit is None or high_limit is None:
+ # Some devices may erroneously report `None` limits.
+ # TyphosPositioner will hide the limit labels in this scenario.
+ ...
+ elif low_limit < high_limit:
+ self.ui.low_limit.setText(str(low_limit))
+ self.ui.high_limit.setText(str(high_limit))
+ return
+
+ # If not found or invalid, hide them:
+ self.ui.low_limit.hide()
+ self.ui.high_limit.hide()
+ self._show_lowtrav = False
+ self._show_hightrav = False
+
+ @utils.linked_attribute('moving_attribute', 'ui.moving_indicator', True)
+ def _link_moving(self, signal, widget):
+ """Link the positioner moving indicator with the ui element."""
+ if signal is None:
+ widget.hide()
+ return False
+ widget.show()
+ # Additional handling for updating self.moving
+ if self._moving_channel is not None:
+ self._moving_channel.disconnect()
+ chname = utils.channel_from_signal(signal)
+ self._moving_channel = PyDMChannel(
+ address=chname,
+ value_slot=self._set_moving,
+ )
+ self._moving_channel.connect()
+ return True
+
+ @utils.linked_attribute('error_message_attribute', 'ui.error_label', True)
+ def _link_error_message(self, signal, widget):
+ """Link the IOC error message with the ui element."""
+ if signal is None:
+ widget.hide()
+
+ def _define_setpoint_widget(self):
+ """
+ Leverage information at describe to define whether to use a
+ PyDMLineEdit or a PyDMEnumCombobox as setpoint widget.
+ """
+ if self.device is None:
+ return
+
+ try:
+ setpoint_signal = getattr(self.device, self.setpoint_attribute)
+ selection = setpoint_signal.enum_strs is not None
+ except Exception:
+ selection = False
+ setpoint_signal = None
+
+ if selection:
+ self.ui.set_value = widgets.NoScrollComboBox()
+ self.ui.set_value.addItems(setpoint_signal.enum_strs)
+ # Activated signal triggers only when the user selects an option
+ self.ui.set_value.activated.connect(self.set)
+ self.ui.set_value.setMinimumContentsLength(20)
+ self.ui.tweak_widget.setVisible(False)
+ else:
+ self.ui.set_value = QtWidgets.QLineEdit()
+ self.ui.set_value.setAlignment(QtCore.Qt.AlignCenter)
+ self.ui.set_value.returnPressed.connect(self.set)
+
+ self.ui.set_value.setSizePolicy(self.ui.user_setpoint.sizePolicy())
+ self.ui.set_value.setMinimumWidth(
+ self.ui.user_setpoint.minimumWidth()
+ )
+ self.ui.set_value.setMaximumWidth(
+ self.ui.user_setpoint.maximumWidth()
+ )
+ self.ui.setpoint_layout.addWidget(
+ self.ui.set_value,
+ alignment=QtCore.Qt.AlignHCenter,
+ )
+ self.ui.set_value.setObjectName('set_value')
+ # Because set_value is used instead
+ self.ui.user_setpoint.setVisible(False)
+
+ @property
+ def device(self):
+ """The associated device."""
+ try:
+ return self.devices[0]
+ except Exception:
+ ...
+
+
+[docs]
+ def add_device(self, device):
+ """Add a device to the widget"""
+ # Add device to cache
+ self.devices.clear() # only one device allowed
+ super().add_device(device)
+
+ self._define_setpoint_widget()
+ self._link_readback()
+ self._link_setpoint()
+ self._link_low_limit_switch()
+ self._link_high_limit_switch()
+
+ # If the stop method is missing, hide the button
+ try:
+ device.stop
+ self.ui.stop_button.show()
+ except AttributeError:
+ self.ui.stop_button.hide()
+
+ if not (self._link_low_travel() and self._link_high_travel()):
+ self._link_limits_by_limits_attr()
+
+ if self._link_moving():
+ self.ui.moving_indicator_label.show()
+ else:
+ self.ui.moving_indicator_label.hide()
+
+ self._link_error_message()
+
+ if self.show_expert_button:
+ self.ui.expert_button.devices.clear()
+ self.ui.expert_button.add_device(device)
+
+ self.ui.alarm_circle.clear_all_alarm_configs()
+ self.ui.alarm_circle.add_device(device)
+
+
+ @QtCore.Property(bool, designable=False)
+ def moving(self):
+ """
+ Current state of widget
+
+ This will lag behind the actual state of the positioner in order to
+ prevent unnecessary rapid movements
+ """
+ return self._moving
+
+ @moving.setter
+ def moving(self, value):
+ if value != self._moving:
+ self._moving = value
+ self._after_set_moving(value)
+
+ def _after_set_moving(self, value):
+ """
+ Common updates needed after a change to the moving state.
+
+ This is pulled out as a separate method because we need
+ to initialize the label here during __init__ without
+ modifying self.moving.
+ """
+ utils.reload_widget_stylesheet(self, cascade=True)
+ if value:
+ self.ui.moving_indicator_label.setText('moving')
+ else:
+ self.ui.moving_indicator_label.setText('done')
+
+ def _set_moving(self, value):
+ """
+ Slot for updating the self.moving property.
+
+ This is used e.g. in updating the moving state when the
+ motor starts moving in EPICS but not by the request of
+ this widget.
+ """
+ self.moving = bool(value)
+
+ @QtCore.Property(bool, designable=False)
+ def successful_move(self):
+ """The last requested move was successful"""
+ return self._last_move is True
+
+ @QtCore.Property(bool, designable=False)
+ def failed_move(self):
+ """The last requested move failed"""
+ return self._last_move is False
+
+ @QtCore.Property(str, designable=True)
+ def readback_attribute(self):
+ """The attribute name for the readback signal."""
+ return self._readback_attr
+
+ @readback_attribute.setter
+ def readback_attribute(self, value):
+ self._readback_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def setpoint_attribute(self):
+ """The attribute name for the setpoint signal."""
+ return self._setpoint_attr
+
+ @setpoint_attribute.setter
+ def setpoint_attribute(self, value):
+ self._setpoint_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def low_limit_switch_attribute(self):
+ """The attribute name for the low limit switch signal."""
+ return self._low_limit_switch_attr
+
+ @low_limit_switch_attribute.setter
+ def low_limit_switch_attribute(self, value):
+ self._low_limit_switch_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def high_limit_switch_attribute(self):
+ """The attribute name for the high limit switch signal."""
+ return self._high_limit_switch_attr
+
+ @high_limit_switch_attribute.setter
+ def high_limit_switch_attribute(self, value):
+ self._high_limit_switch_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def low_limit_travel_attribute(self):
+ """The attribute name for the low limit signal."""
+ return self._low_limit_travel_attr
+
+ @low_limit_travel_attribute.setter
+ def low_limit_travel_attribute(self, value):
+ self._low_limit_travel_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def high_limit_travel_attribute(self):
+ """The attribute name for the high (soft) limit travel signal."""
+ return self._high_limit_travel_attr
+
+ @high_limit_travel_attribute.setter
+ def high_limit_travel_attribute(self, value):
+ self._high_limit_travel_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def velocity_attribute(self):
+ """The attribute name for the velocity signal."""
+ return self._velocity_attr
+
+ @velocity_attribute.setter
+ def velocity_attribute(self, value):
+ self._velocity_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def acceleration_attribute(self):
+ """The attribute name for the acceleration time signal."""
+ return self._acceleration_attr
+
+ @acceleration_attribute.setter
+ def acceleration_attribute(self, value):
+ self._acceleration_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def moving_attribute(self):
+ """The attribute name for the motor moving indicator."""
+ return self._moving_attr
+
+ @moving_attribute.setter
+ def moving_attribute(self, value):
+ self._moving_attr = value
+
+ @QtCore.Property(str, designable=True)
+ def error_message_attribute(self):
+ """The attribute name for the IOC error message label."""
+ return self._error_message_attr
+
+ @error_message_attribute.setter
+ def error_message_attribute(self, value):
+ self._error_message_attr = value
+
+ @QtCore.Property(bool, designable=True)
+ def show_expert_button(self):
+ """
+ If True, show the expert button.
+
+ The expert button opens a full suite for the device.
+ You typically want this False when you're already inside the
+ suite that the button would open.
+ You typically want this True when you're using the positioner widget
+ inside of an unrelated screen.
+ This will default to False.
+ """
+ return self._show_expert_button
+
+ @show_expert_button.setter
+ def show_expert_button(self, show):
+ self._show_expert_button = show
+ if show:
+ self.ui.expert_button.show()
+ else:
+ self.ui.expert_button.hide()
+
+ @QtCore.Property(_KindLevel, designable=True)
+ def alarmKindLevel(self) -> KindLevel:
+ return self.ui.alarm_circle.kindLevel
+
+ @alarmKindLevel.setter
+ def alarmKindLevel(self, kind_level: KindLevel):
+ if kind_level != self.alarmKindLevel:
+ self.ui.alarm_circle.kindLevel = kind_level
+
+ def _move_started(self) -> None:
+ """Called when a move is begun"""
+ logger.debug("Begin showing move in TyphosPositionerWidget")
+ self.moving = True
+
+ def _set_status_text(
+ self,
+ message: TyphosStatusMessage | str,
+ *,
+ max_length: int | None = 60,
+ ) -> str:
+ """
+ Set the status text label to the contents of ``message``.
+
+ Message is either a simple string or a dataclass that
+ has a separate entry for the text and for the tooltip.
+
+ Simple strings or empty string tooltips will result in
+ a widget with no tooltip, unless the message is longer
+ than the max length.
+
+ Messages that are longer than the max length will be
+ truncated and also included in the tooltip.
+
+ Parameters
+ ----------
+ message : TyphosStatusMessage or str
+ The message to include in the status text.
+ max_length : int or None, optional
+ The maximum length for the status text before it gets
+ truncated and moved to the tooltip. If this is manually
+ set to ``None``, there will be no limit.
+
+ Returns
+ -------
+ text : str
+ The text that is displayed in the status label, which
+ may be truncated.
+ """
+ if isinstance(message, TyphosStatusMessage):
+ text = message.text
+ tooltip = message.tooltip
+ elif isinstance(message, str):
+ text = message
+ tooltip = ""
+ if max_length is not None and len(text) >= max_length:
+ if tooltip:
+ tooltip = f"{text}: {tooltip}"
+ else:
+ tooltip = text
+ text = message.text[:max_length] + '...'
+ self.ui.status_label.setText(text)
+ if tooltip and "\n" not in tooltip:
+ # Force rich text, qt auto line wraps if it detects rich text
+ tooltip = f"<html><head/><body><p>{tooltip}</p></body></html>"
+ self.ui.status_label.setToolTip(tooltip)
+ return text
+
+ def _status_finished(self, result: TyphosStatusResult | Exception) -> None:
+ """Called when a move is complete."""
+ success = False
+ if isinstance(result, Exception):
+ # Calling set or move completely broke
+ self._set_status_text(f"<b>{result.__class__.__name__}</b> {result}")
+ elif result == TyphosStatusResult.success:
+ # Clear the status display of any lingering timeout text
+ self._set_status_text("")
+ success = True
+ # Other cases: keep the existing status text, whatever it is.
+ # This covers any case where the move started, but had an error during the move.
+ logger.debug(
+ "Completed move in TyphosPositionerWidget (result=%r)",
+ result,
+ )
+ self._last_move = success
+ self.moving = False
+
+ @QtCore.Slot(str)
+ def _user_setpoint_update(self, text):
+ """Qt slot - indicating the ``user_setpoint`` widget text changed."""
+ try:
+ text = text.strip().split(' ')[0]
+ text = text.strip()
+ except Exception:
+ return
+
+ # Update set_value if it's not being edited.
+ if not self.ui.set_value.hasFocus():
+ if isinstance(self.ui.set_value, widgets.NoScrollComboBox):
+ try:
+ idx = int(text)
+ self.ui.set_value.setCurrentIndex(idx)
+ self._initialized = True
+ except ValueError:
+ logger.debug('Failed to convert value to int. %s', text)
+ else:
+ self._initialized = True
+ self.ui.set_value.setText(text)
+
+
+[docs]
+ def update_alarm_text(self, alarm_level):
+ """
+ Label the alarm circle with a short text bit.
+ """
+ alarms = self.ui.alarm_circle.AlarmLevel
+ try:
+ text = self.alarm_text[alarm_level]
+ except KeyError:
+ text = self.alarm_text[alarms.INVALID]
+ self.ui.alarm_label.setText(text)
+
+
+ @property
+ def all_linked_attributes(self) -> list[str]:
+ """All linked attribute names."""
+ return [
+ attr
+ for attr in (
+ self.acceleration_attribute,
+ self.error_message_attribute,
+ self.high_limit_switch_attribute,
+ self.high_limit_travel_attribute,
+ self.low_limit_switch_attribute,
+ self.low_limit_travel_attribute,
+ self.moving_attribute,
+ self.readback_attribute,
+ self.setpoint_attribute,
+ self.velocity_attribute,
+ )
+ if attr
+ ]
+
+ @property
+ def all_linked_signals(self) -> list[ophyd.Signal]:
+ """All linked signal names."""
+ signals = [
+ getattr(self.device, attr, None)
+ for attr in self.all_linked_attributes
+ ]
+ return [sig for sig in signals if sig is not None]
+
+
+[docs]
+ def show_ui_type_hints(self):
+ """Show type hints of widgets included in the UI file."""
+ cls_attrs = set()
+ obj_attrs = set(dir(self.ui))
+ annotated = set(self.ui.__annotations__)
+ for cls in type(self.ui).mro():
+ cls_attrs |= set(dir(cls))
+ likely_from_ui = obj_attrs - cls_attrs - annotated
+ for attr in sorted(likely_from_ui):
+ try:
+ obj = getattr(self, attr, None)
+ except Exception:
+ ...
+ else:
+ if obj is not None:
+ print(f"{attr}: {obj.__class__.__module__}.{obj.__class__.__name__}")
+
+
+
+
+class _TyphosPositionerRowUI(_TyphosPositionerUI):
+ """Annotations helper for positioner_row.ui; not to be instantiated."""
+
+ notes_edit: TyphosNotesEdit
+ status_container_widget: QtWidgets.QWidget
+ extended_signal_panel: Optional[RowDetails]
+ switcher: TyphosDisplaySwitcher
+ status_text_layout: None # Row UI doesn't use status_text_layout
+ low_limit_widget: QtWidgets.QWidget
+ high_limit_widget: QtWidgets.QWidget
+ setpoint_widget: QtWidgets.QWidget
+
+
+class TyphosPositionerRowWidget(TyphosPositionerWidget):
+ ui: _TyphosPositionerRowUI
+ ui_template = os.path.join(utils.ui_dir, "widgets", "positioner_row.ui")
+
+ alarm_text = {
+ AlarmLevel.NO_ALARM: 'ok',
+ AlarmLevel.MINOR: 'minor',
+ AlarmLevel.MAJOR: 'major',
+ AlarmLevel.DISCONNECTED: 'conn',
+ AlarmLevel.INVALID: 'inv',
+ }
+
+ def __init__(self, *args, **kwargs):
+ self._error_message = ""
+ self._status_text = ""
+ self._alarm_level = AlarmLevel.DISCONNECTED
+
+ super().__init__(*args, **kwargs)
+ dynamic_font.patch_widget(self.ui.low_limit, pad_percent=0.01, max_size=12, min_size=4)
+ dynamic_font.patch_widget(self.ui.high_limit, pad_percent=0.01, max_size=12, min_size=4)
+ dynamic_font.patch_widget(self.ui.device_name_label, pad_percent=0.01, min_size=4)
+ dynamic_font.patch_widget(self.ui.notes_edit, pad_percent=0.01, min_size=4)
+ dynamic_font.patch_widget(self.ui.alarm_label, pad_percent=0.01, min_size=4)
+ dynamic_font.patch_widget(self.ui.moving_indicator_label, pad_percent=0.01, min_size=4)
+ dynamic_font.patch_widget(self.ui.stop_button, pad_percent=0.4, min_size=4)
+ dynamic_font.patch_widget(self.ui.expert_button, pad_percent=0.3, min_size=4)
+ dynamic_font.patch_widget(self.ui.clear_error_button, pad_percent=0.3, min_size=4)
+
+ # TODO move these out
+ self._omit_names = [
+ "motor_egu",
+ "motor_stop",
+ "motor_done_move",
+ "direction_of_travel",
+ "user_readback",
+ "user_setpoint",
+ "home_forward", # maybe keep?
+ "home_reverse",
+ ]
+
+ self.ui.extended_signal_panel = None
+ self.ui.expand_button.clicked.connect(self._expand_layout)
+ self.ui.status_label.setText("")
+
+ # TODO: ${name} / macros don't expand here
+
+ @QtCore.Property("QStringList")
+ def omitNames(self) -> list[str]:
+ """Get or set the list of names to omit in the expanded signal panel."""
+ return self._omit_names
+
+ @omitNames.setter
+ def omitNames(self, omit_names: list[str]) -> None:
+ if omit_names == self._omit_names:
+ return
+
+ self._omit_names = list(omit_names or [])
+ if self.ui.extended_signal_panel is not None:
+ self.ui.extended_signal_panel.omitNames = self._omit_names
+
+ def get_names_to_omit(self) -> list[str]:
+ """
+ Get a list of signal names to omit in the extended panel.
+
+ Returns
+ -------
+ list[str]
+ """
+ device: Optional[ophyd.Device] = self.device
+ if device is None:
+ return []
+
+ omit_signals = self.all_linked_signals
+ to_keep_signals = [
+ getattr(device, attr, None)
+ for attr in (
+ self.velocity_attribute,
+ self.acceleration_attribute,
+ self.high_limit_travel_attribute,
+ self.low_limit_travel_attribute,
+ )
+ ]
+ for sig in to_keep_signals:
+ if sig in omit_signals:
+ omit_signals.remove(sig)
+
+ to_omit = set(sig.name for sig in omit_signals)
+
+ # TODO: move these to a Qt designable property
+ for name in self.omitNames:
+ to_omit.add(name)
+
+ if device.name in to_omit:
+ # Don't let the renamed position signal stop us from showing any
+ # signals:
+ to_omit.remove(device.name)
+ return sorted(to_omit)
+
+ def _create_signal_panel(self) -> Optional[TyphosSignalPanel]:
+ """Create the 'extended' TyphosSignalPanel for the device."""
+ if self.device is None:
+ return None
+
+ return RowDetails(row=self, parent=self, flags=QtCore.Qt.Window)
+
+ def _expand_layout(self) -> None:
+ """Toggle the expansion of the signal panel."""
+ if self.ui.extended_signal_panel is None:
+ self.ui.extended_signal_panel = self._create_signal_panel()
+ if self.ui.extended_signal_panel is None:
+ return
+ to_show = True
+ else:
+ to_show = not self.ui.extended_signal_panel.isVisible()
+
+ self.ui.extended_signal_panel.setVisible(to_show)
+
+ def add_device(self, device: ophyd.Device) -> None:
+ """Add (or rather set) the ophyd device for this positioner."""
+ super().add_device(device)
+ if device is None:
+ self.ui.device_name_label.setText("(no device)")
+ if self.ui.extended_signal_panel is not None:
+ self.layout().removeWidget(self.ui.extended_signal_panel)
+ self.ui.extended_signal_panel.destroyLater()
+ self.ui.extended_signal_panel = None
+ return
+
+ self.ui.device_name_label.setText(device.name)
+ self.ui.notes_edit.add_device(device)
+ self.ui.switcher.help_toggle_button.setToolTip(self._get_tooltip())
+ self.ui.switcher.help_toggle_button.setEnabled(False)
+
+ if not any((
+ self._show_lowlim,
+ self._show_highlim,
+ self._show_lowtrav,
+ self._show_hightrav,
+ )):
+ # Hide the limit sections
+ self.ui.low_limit_widget.hide()
+ self.ui.high_limit_widget.hide()
+
+ def _get_tooltip(self):
+ """Update the tooltip based on device information."""
+ # Lifted from TyphosHelpFrame
+ tooltip = []
+ # BUG: I'm seeing two devices in `self.devices` for
+ # $ typhos --fake-device 'ophyd.EpicsMotor[{"prefix":"b"}]'
+ for device in sorted(
+ set(self.devices),
+ key=lambda dev: self.devices.index(dev)
+ ):
+ heading = device.name or type(device).__name__
+ tooltip.extend([
+ heading,
+ "-" * len(heading),
+ ""
+ ])
+
+ tooltip.append(
+ inspect.getdoc(device) or
+ inspect.getdoc(type(device)) or
+ "No docstring"
+ )
+ tooltip.append("")
+ return "\n".join(tooltip)
+
+ @utils.linked_attribute('error_message_attribute', 'ui.error_label', True)
+ def _link_error_message(self, signal, widget):
+ """Link the IOC error message with the ui element."""
+ if signal is None:
+ widget.hide()
+ else:
+ signal.subscribe(self.new_error_message)
+
+ def new_error_message(self, value, *args, **kwargs):
+ self.update_status_visibility(error_message=value)
+
+ def _define_setpoint_widget(self):
+ super()._define_setpoint_widget()
+ if isinstance(self.ui.set_value, QtWidgets.QComboBox):
+ # Pad extra to avoid intersecting drop-down arrow
+ dynamic_font.patch_widget(self.ui.set_value, pad_percent=0.18, min_size=4)
+ # Consume the vertical space left by the missing tweak widgets
+ self.ui.set_value.setMinimumHeight(
+ self.ui.user_setpoint.minimumHeight()
+ + self.ui.tweak_value.minimumHeight()
+ )
+ else:
+ dynamic_font.patch_widget(self.ui.set_value, pad_percent=0.1, min_size=4)
+
+ def _set_status_text(
+ self,
+ message: TyphosStatusMessage | str,
+ *,
+ max_length: int | None = None,
+ ) -> str:
+ text = super()._set_status_text(message, max_length=max_length)
+ self.update_status_visibility(status_text=text)
+ return text
+
+ def update_alarm_text(self, alarm_level):
+ super().update_alarm_text(alarm_level=alarm_level)
+ self.update_status_visibility(alarm_level=alarm_level)
+
+ def update_status_visibility(
+ self,
+ error_message: str | None = None,
+ status_text: str | None = None,
+ alarm_level: AlarmLevel | None = None,
+ ) -> None:
+ """
+ Hide/show status and error as appropriate.
+
+ The goal here to make an illusion that there is only one label in
+ in this space when only one of the labels has text.
+
+ If both are empty, we want to put "something" there to fill the
+ void, so we opt for a friendly message or an alarm reminder.
+
+ If both are populated, we want to do some best-effort deduplication.
+ """
+ if error_message is not None:
+ self._error_message = error_message
+ if status_text is not None:
+ self._status_text = status_text
+ if alarm_level is not None:
+ self._alarm_level = alarm_level
+ error_message = error_message or self._error_message
+ status_text = status_text or self._status_text
+ alarm_level = alarm_level or self._alarm_level
+ has_status = bool(status_text)
+ has_error = bool(error_message)
+ if not has_status and not has_error:
+ # We want to fill something in, check if we have alarms
+ if alarm_level == AlarmLevel.NO_ALARM:
+ self.ui.status_label.setText('Status OK')
+ else:
+ self.ui.status_label.setText('Check alarm')
+ has_status = True
+ if has_status and has_error:
+ # We want to avoid having duplicate information (low effort try)
+ if error_message in status_text:
+ has_error = False
+ self.ui.status_label.setVisible(has_status)
+ self.ui.error_label.setVisible(has_error)
+
+
+def clear_error_in_background(device):
+ def inner():
+ try:
+ device.clear_error()
+ except AttributeError:
+ pass
+ except Exception:
+ msg = "Could not clear error!"
+ logger.error(msg)
+ logger.debug(msg, exc_info=True)
+
+ td = threading.Thread(target=inner, daemon=True)
+ td.start()
+
+
+class RowDetails(QtWidgets.QWidget):
+ """
+ Container class for floating window with positioner row's basic config info.
+ """
+ row: TyphosPositionerRowWidget
+ resize_timer: QtCore.QTimer
+
+ def __init__(self, row: TyphosPositionerRowWidget, parent: QtWidgets.QWidget | None = None, **kwargs):
+ super().__init__(parent=parent, **kwargs)
+ self.row = row
+
+ self.label = QtWidgets.QLabel()
+ self.label.setText(row.ui.device_name_label.text())
+ font = self.label.font()
+ font.setPointSize(font.pointSize() + 4)
+ self.label.setFont(font)
+ self.label.setMaximumHeight(
+ QtGui.QFontMetrics(font).boundingRect(self.label.text()).height()
+ )
+
+ self.panel = TyphosSignalPanel()
+ self.panel.omitNames = row.get_names_to_omit()
+ self.panel.sortBy = SignalOrder.byName
+ self.panel.add_device(row.device)
+
+ self.scroll_area = QtWidgets.QScrollArea()
+ self.scroll_area.setFrameStyle(QtWidgets.QFrame.NoFrame)
+ self.scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
+ self.scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.setWidget(self.panel)
+
+ layout = QtWidgets.QVBoxLayout()
+ layout.addWidget(self.label)
+ layout.addWidget(self.scroll_area)
+
+ self.setLayout(layout)
+ self.resize_timer = QtCore.QTimer(parent=self)
+ self.resize_timer.timeout.connect(self.fix_scroll_size)
+ self.resize_timer.setInterval(1)
+ self.resize_timer.setSingleShot(True)
+
+ self.original_panel_min_width = self.panel.minimumWidth()
+ self.last_resize_width = 0
+ self.resize_done = False
+
+ def hideEvent(self, event: QtGui.QHideEvent):
+ """
+ After hide, update button text, even if we were hidden via clicking the "x".
+ """
+ self.row.ui.expand_button.setText('>')
+ return super().hideEvent(event)
+
+ def showEvent(self, event: QtGui.QShowEvent):
+ """
+ Before show, update button text and move window to just under button.
+ """
+ button = self.row.ui.expand_button
+ button.setText('v')
+ self.move(
+ button.mapToGlobal(
+ QtCore.QPoint(
+ button.pos().x(),
+ button.pos().y() + button.height()
+ + self.style().pixelMetric(QtWidgets.QStyle.PM_TitleBarHeight),
+ )
+ )
+ )
+ if not self.resize_done:
+ self.resize_timer.start()
+ return super().showEvent(event)
+
+ def fix_scroll_size(self):
+ """
+ Slot that ensures the panel gets enough space in the scroll area.
+
+ The panel, when created, has smaller sizing information than it does
+ a few moments after being shown for the first time. This might
+ update several times before settling down.
+
+ We want to watch for this resize and set the scroll area width such
+ that there's enough room to see the widget at its minimum size.
+ """
+ if self.panel.minimumWidth() <= self.original_panel_min_width:
+ # No change
+ self.resize_timer.start()
+ return
+ elif self.last_resize_width != self.panel.minimumWidth():
+ # We are not stable yet
+ self.last_resize_width = self.panel.minimumWidth()
+ self.resize_timer.start()
+ return
+
+ # Make sure the panel has enough space to exist!
+ self.scroll_area.setMinimumWidth(
+ self.panel.minimumWidth()
+ + self.style().pixelMetric(QtWidgets.QStyle.PM_ScrollBarExtent)
+ + 2 * self.style().pixelMetric(QtWidgets.QStyle.PM_ScrollView_ScrollBarOverlap)
+ + 2 * self.style().pixelMetric(QtWidgets.QStyle.PM_ScrollView_ScrollBarSpacing)
+ )
+ self.resize_done = True
+
+"""
+The high-level Typhos Suite, which bundles tools and panels.
+"""
+from __future__ import annotations
+
+import logging
+import os
+import pathlib
+import textwrap
+from functools import partial
+from typing import Optional, Union
+
+import ophyd
+import pcdsutils.qt
+from ophyd import Device
+from pyqtgraph import parametertree
+from pyqtgraph.parametertree import parameterTypes as ptypes
+from qtpy import QtCore, QtGui, QtWidgets
+
+from . import utils, widgets
+from .display import DisplayTypes, ScrollOptions, TyphosDeviceDisplay
+from .tools import TyphosLogDisplay, TyphosTimePlot
+from .utils import (TyphosBase, TyphosException, clean_attr, clean_name,
+ flatten_tree, save_suite)
+
+logger = logging.getLogger(__name__)
+# Use non-None sentinel value since None means no tools
+DEFAULT_TOOLS = object()
+
+
+class SidebarParameter(parametertree.Parameter):
+ """
+ Parameter to hold information for the sidebar.
+
+ Attributes
+ ----------
+ itemClass : type
+ The class to be used for the parameter.
+
+ sigOpen : QtCore.Signal
+ A signal indicating an open request for the parameter.
+
+ sigHide : QtCore.Signal
+ A signal indicating an hide request for the parameter.
+
+ sigEmbed : QtCore.Signal
+ A signal indicating an embed request for the parameter.
+ """
+
+ itemClass = widgets.TyphosSidebarItem
+ sigOpen = QtCore.Signal(object)
+ sigHide = QtCore.Signal(object)
+ sigEmbed = QtCore.Signal(object)
+
+ def __init__(self, devices=None, embeddable=None, **opts):
+ super().__init__(**opts)
+ self.embeddable = embeddable
+ self.devices = list(devices) if devices else []
+
+ def has_device(self, device: ophyd.Device):
+ """
+ Determine if this parameter contains the given device.
+
+ Parameters
+ ----------
+ device : ophyd.OphydObj or str
+ The device or its name.
+
+ Returns
+ -------
+ has_device : bool
+ """
+ return any(
+ (device in self.devices,
+ device in getattr(self.value(), 'devices', []),
+ self.name() == device,
+ isinstance(device, str) and self.name() == clean_attr(device),
+ )
+ )
+
+
+class TyphosDisplayNotCreatedError(TyphosException):
+ """The given subdisplay has not yet been shown."""
+ ...
+
+
+class LazySubdisplay(QtWidgets.QWidget):
+ """
+ A lazy subdisplay which only is instantiated when shown in the suite.
+
+ Supports devices by way of ``add_device``.
+
+ Parameters
+ ----------
+ widget_cls : QtWidgets.QWidget subclass
+ The widget class to instantiate.
+ """
+
+ widget_cls: type[QtWidgets.QWidget]
+ widget: QtWidgets.QWidget | None
+ devices: list[ophyd.Device]
+
+ def __init__(self, widget_cls: type[QtWidgets.QWidget]):
+ super().__init__()
+ self.widget_cls = widget_cls
+ self.widget = None
+
+ self.setVisible(False)
+ self.setLayout(QtWidgets.QVBoxLayout())
+ self.devices = []
+
+ def add_device(self, device: ophyd.Device):
+ """Hook for adding a device from the suite."""
+ self.devices.append(device)
+
+ def hideEvent(self, event: QtGui.QHideEvent):
+ """Hook for when the tool is hidden."""
+ return super().hideEvent(event)
+
+ def _create_widget(self):
+ """Make the widget no longer lazy."""
+ if self.widget is not None:
+ return
+
+ self.widget = self.widget_cls()
+ self.layout().addWidget(self.widget)
+ self.setSizePolicy(self.widget.sizePolicy())
+
+ if hasattr(self.widget, "add_device"):
+ for device in self.devices:
+ self.widget.add_device(device)
+
+ def showEvent(self, event: QtGui.QShowEvent):
+ """Hook for when the tool is shown in the suite."""
+ if self.widget is None:
+ self._create_widget()
+
+ return super().showEvent(event)
+
+ def minimumSizeHint(self):
+ """Minimum size hint forwarder from the embedded widget."""
+ if self.widget is not None:
+ return self.widget.minimumSizeHint()
+ return self.sizeHint()
+
+ def sizeHint(self):
+ """Size hint forwarder from the embedded widget."""
+ if self.widget is not None:
+ return self.widget.sizeHint()
+ return QtCore.QSize(100, 100)
+
+
+class DeviceParameter(SidebarParameter):
+ """
+ Parameter to hold information on an Ophyd Device.
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ The device instance.
+
+ subdevices : bool, optional
+ Include child parameters for sub devices of ``device``.
+
+ **opts
+ Passed to super().__init__.
+ """
+
+ itemClass = widgets.TyphosSidebarItem
+
+ def __init__(self, device, subdevices=True, **opts):
+ # Set options for parameter
+ opts['name'] = clean_name(device, strip_parent=device.root)
+ self.device = device
+ opts['expanded'] = False
+ # Grab children from the given device
+ children = list()
+ if subdevices:
+ for child in device._sub_devices:
+ subdevice = getattr(device, child)
+ if subdevice._sub_devices:
+ # If that device has children, make sure they are also
+ # displayed further in the tree
+ children.append(
+ DeviceParameter(subdevice, subdevices=False)
+ )
+ else:
+ # Otherwise just make a regular parameter out of it
+ child_name = clean_name(subdevice,
+ strip_parent=subdevice.root)
+ param = SidebarParameter(
+ value=partial(TyphosDeviceDisplay.from_device,
+ subdevice),
+ name=child_name,
+ embeddable=True,
+ devices=[subdevice],
+ )
+ children.append(param)
+
+ opts['children'] = children
+ super().__init__(
+ value=partial(TyphosDeviceDisplay.from_device, device),
+ embeddable=opts.pop('embeddable', True),
+ devices=[device],
+ **opts
+ )
+
+
+
+[docs]
+class TyphosSuite(TyphosBase):
+ """
+ This suite combines tools and devices into a single widget.
+
+ A :class:`ParameterTree` is contained in a :class:`~pcdsutils.qt.QPopBar`
+ which shows tools and the hierarchy of a device along with options to
+ show or hide them.
+
+ Parameters
+ ----------
+ parent : QWidget, optional
+
+ pin : bool, optional
+ Pin the parameter tree on startup.
+
+ content_layout : QLayout, optional
+ Sets the layout for when we have multiple subdisplays
+ open in the suite. This will have a horizontal layout by
+ default but can be changed as needed for the use case.
+
+ default_display_type : DisplayType, optional
+ DisplayType enum that determines the type of display to open when we
+ add a device to the suite. Defaults to DisplayType.detailed_screen.
+
+ scroll_option : ScrollOptions, optional
+ ScrollOptions enum that determines the behavior of scrollbars
+ in the suite. Default is ScrollOptions.auto, which enables
+ scrollbars for detailed and engineering screens but not for
+ embedded displays.
+
+ Attributes
+ ----------
+ default_tools : dict
+ The default tools to use in the suite. In the form of
+ ``{'tool_name': ToolClass}``.
+ """
+
+ DEFAULT_TITLE = 'Typhos Suite'
+ DEFAULT_TITLE_DEVICE = 'Typhos Suite - {device.name}'
+
+ default_tools = {
+ "Log": TyphosLogDisplay,
+ "StripTool": TyphosTimePlot,
+ }
+
+ def __init__(
+ self,
+ parent: QtWidgets.QWidget | None = None,
+ *,
+ pin: bool = False,
+ content_layout: QtWidgets.QLayout | None = None,
+ default_display_type: DisplayTypes = DisplayTypes.detailed_screen,
+ scroll_option: ScrollOptions = ScrollOptions.auto,
+ ):
+ super().__init__(parent=parent)
+
+ self._update_title()
+
+ self._tree = parametertree.ParameterTree(parent=self, showHeader=False)
+ self._tree.setAlternatingRowColors(False)
+ self._save_action = ptypes.ActionParameter(name='Save Suite')
+ self._tree.addParameters(self._save_action)
+ self._save_action.sigActivated.connect(self.save)
+
+ self._bar = pcdsutils.qt.QPopBar(title='Suite', parent=self,
+ widget=self._tree, pin=pin)
+
+ self._tree.setSizePolicy(
+ QtWidgets.QSizePolicy.MinimumExpanding,
+ QtWidgets.QSizePolicy.MinimumExpanding
+ )
+ self._tree.setMinimumSize(250, 150)
+
+ self._content_frame = QtWidgets.QFrame(self)
+ self._content_frame.setObjectName("content")
+ self._content_frame.setFrameShape(QtWidgets.QFrame.StyledPanel)
+
+ # Content frame layout: configurable
+ # Defaults to [content] [content] [content] ... in one line
+ if content_layout is None:
+ content_layout = QtWidgets.QHBoxLayout()
+ self._content_frame.setLayout(content_layout)
+
+ # Horizontal box layout: [PopBar] [Content Frame]
+ layout = QtWidgets.QHBoxLayout()
+ self.setLayout(layout)
+ layout.setSpacing(1)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self._bar)
+ layout.addWidget(self._content_frame)
+
+ self.embedded_dock = None
+ self.default_display_type = default_display_type
+ self.scroll_option = scroll_option
+
+
+[docs]
+ def add_subdisplay(self, name, display, category):
+ """
+ Add an arbitrary widget to the tree of available widgets and tools.
+
+ Parameters
+ ----------
+ name : str
+ Name to be displayed in the tree
+
+ display : QWidget
+ QWidget to show in the dock when expanded.
+
+ category : str
+ The top level group to place the controls under in the tree. If the
+ category does not exist, a new one will be made
+ """
+ logger.debug("Adding widget %r with %r to %r ...",
+ name, display, category)
+ # Create our parameter
+ parameter = SidebarParameter(value=display, name=name)
+ self._add_to_sidebar(parameter, category)
+
+
+
+[docs]
+ def add_lazy_subdisplay(
+ self, name: str, display_class: type[QtWidgets.QWidget], category: str
+ ):
+ """
+ Add an arbitrary widget to the tree of available widgets and tools.
+
+ Parameters
+ ----------
+ name : str
+ Name to be displayed in the tree
+
+ display_class : subclass of QWidget
+ QWidget class to show in the dock when expanded.
+
+ category : str
+ The top level group to place the controls under in the tree. If the
+ category does not exist, a new one will be made
+ """
+ logger.debug("Adding lazy subdisplay %r with %r to %r ...",
+ name, display_class, category)
+ # Create our parameter
+ parameter = SidebarParameter(
+ value=LazySubdisplay(display_class),
+ name=name
+ )
+ self._add_to_sidebar(parameter, category)
+
+
+ @property
+ def top_level_groups(self):
+ """
+ Get top-level groups.
+
+ This is of the form:
+
+ .. code:: python
+
+ {'name': QGroupParameterItem}
+ """
+ root = self._tree.invisibleRootItem()
+ return {root.child(idx).param.name():
+ root.child(idx).param
+ for idx in range(root.childCount())}
+
+
+[docs]
+ def add_tool(self, name: str, tool: type[QtWidgets.QWidget]):
+ """
+ Add a widget to the toolbar.
+
+ Shortcut for:
+
+ .. code:: python
+
+ suite.add_subdisplay(name, tool, category='Tools')
+
+ Parameters
+ ----------
+ name : str
+ Name of tool to be displayed in sidebar
+
+ tool : QWidget
+ Widget to be added to ``.ui.subdisplay``
+ """
+ self.add_lazy_subdisplay(name, tool, "Tools")
+
+
+
+[docs]
+ def get_subdisplay(self, display: Union[Device, str], instantiate: bool = True):
+ """
+ Get a subdisplay by name or contained device.
+
+ Parameters
+ ----------
+ display : str or Device
+ Name of screen or device
+ instantiate : bool, optional
+ Instantiate lazy sub-displays if they do not already exist.
+ Raise otherwise.
+
+ Returns
+ -------
+ widget : QWidget or partial
+ Widget that is a member of the :attr:`.ui.subdisplay`
+
+ Example
+ -------
+ .. code:: python
+
+ suite.get_subdisplay(my_device.x)
+ suite.get_subdisplay('My Tool')
+ """
+ if not isinstance(display, SidebarParameter):
+ for group in self.top_level_groups.values():
+ tree = flatten_tree(group)
+ matches = [
+ param for param in tree
+ if hasattr(param, 'has_device') and
+ param.has_device(display)
+ ]
+
+ if matches:
+ display = matches[0]
+ break
+
+ if not isinstance(display, SidebarParameter):
+ # If we got here we can't find the subdisplay
+ raise ValueError(f"Unable to find subdisplay {display}")
+
+ subdisplay = display.value()
+ if isinstance(subdisplay, partial):
+ if not instantiate:
+ raise TyphosDisplayNotCreatedError(
+ f"Subdisplay {display} has not been created yet"
+ )
+
+ subdisplay = subdisplay()
+ display.setValue(subdisplay)
+ return subdisplay
+
+
+
+[docs]
+ @QtCore.Slot(str)
+ @QtCore.Slot(object)
+ def show_subdisplay(
+ self,
+ widget: Union[QtWidgets.QWidget, SidebarParameter, str],
+ ) -> QtWidgets.QWidget:
+ """
+ Open a display in the dock system.
+
+ Parameters
+ ----------
+ widget : QWidget, SidebarParameter or str
+ If given a ``SidebarParameter`` from the tree, the widget will be
+ shown and the sidebar item update. Otherwise, the information is
+ passed to :meth:`.get_subdisplay`
+
+ Returns
+ -------
+ widget : QWidget
+ The subdisplay that was shown.
+ """
+ # Grab true widget
+ if not isinstance(widget, QtWidgets.QWidget):
+ widget = self.get_subdisplay(widget)
+
+ # Setup the dock
+ dock = widgets.SubDisplay(self)
+ # Set sidebar properly
+ self._show_sidebar(widget, dock)
+ # Add the widget to the dock
+ logger.debug("Showing widget %r ...", widget)
+ if hasattr(widget, 'scroll_option'):
+ widget.scroll_option = self.scroll_option
+ if hasattr(widget, "display_type"):
+ # Setting a display_type implicitly loads the best template.
+ widget.display_type = self.default_display_type
+ dock.setWidget(widget)
+
+ # Add to layout
+ content_layout = self._content_frame.layout()
+ content_layout.addWidget(dock)
+ if isinstance(content_layout, QtWidgets.QGridLayout):
+ self._content_frame.layout().setAlignment(
+ dock, QtCore.Qt.AlignHCenter
+ )
+ self._content_frame.layout().setAlignment(
+ dock, QtCore.Qt.AlignTop
+ )
+
+ self._new_template()
+ if isinstance(widget, TyphosDeviceDisplay):
+ widget.template_changed.connect(self._new_template)
+ return widget
+
+
+ def _new_template(self, template: Optional[pathlib.Path] = None) -> None:
+ """Hook for when a new template is selected in a sub-display."""
+ if self.parent() is not None:
+ return
+
+ new_width = self.minimumSizeHint().width()
+ if self.width() < new_width:
+ self.resize(new_width, self.height())
+
+
+[docs]
+ @QtCore.Slot(str)
+ @QtCore.Slot(object)
+ def embed_subdisplay(self, widget):
+ """Embed a display in the dock system."""
+ # Grab the relevant display
+ if not self.embedded_dock:
+ self.embedded_dock = widgets.SubDisplay()
+ self.embedded_dock.setWidget(QtWidgets.QWidget())
+ self.embedded_dock.widget().setLayout(QtWidgets.QVBoxLayout())
+ self.embedded_dock.widget().layout().addStretch(1)
+ self._content_frame.layout().addWidget(self.embedded_dock)
+
+ if not isinstance(widget, QtWidgets.QWidget):
+ widget = self.get_subdisplay(widget)
+ # Set sidebar properly
+ self._show_sidebar(widget, self.embedded_dock)
+ # Set our widget to be embedded
+ widget.setVisible(True)
+ widget.display_type = widget.embedded_screen
+ widget_count = self.embedded_dock.widget().layout().count()
+ self.embedded_dock.widget().layout().insertWidget(widget_count - 1,
+ widget)
+
+
+
+[docs]
+ @QtCore.Slot()
+ @QtCore.Slot(object)
+ def hide_subdisplay(self, widget):
+ """
+ Hide a visible subdisplay.
+
+ Parameters
+ ----------
+ widget: SidebarParameter or Subdisplay
+ If you give a SidebarParameter, we will find the corresponding
+ widget and hide it. If the widget provided to us is inside a
+ DockWidget we will close that, otherwise the widget is just hidden.
+ """
+ if not isinstance(widget, QtWidgets.QWidget):
+ try:
+ widget = self.get_subdisplay(widget, instantiate=False)
+ except TyphosDisplayNotCreatedError:
+ logger.debug("Subdisplay was never shown; nothing to do: %s", widget)
+ return
+
+ sidebar = self._get_sidebar(widget)
+ if sidebar:
+ for item in sidebar.items:
+ item._mark_hidden()
+ else:
+ logger.warning("Unable to find sidebar item for %r", widget)
+ # Make sure the actual widget is hidden
+ logger.debug("Hiding widget %r ...", widget)
+ if isinstance(widget.parent(), QtWidgets.QDockWidget):
+ logger.debug("Closing dock ...")
+ widget.parent().close()
+ # Hide the full dock if this is the last widget
+ elif (self.embedded_dock
+ and widget.parent() == self.embedded_dock.widget()):
+ logger.debug("Removing %r from embedded widget layout ...",
+ widget)
+ self.embedded_dock.widget().layout().removeWidget(widget)
+ widget.hide()
+ if self.embedded_dock.widget().layout().count() == 1:
+ logger.debug("Closing embedded layout ...")
+ self.embedded_dock.close()
+ self.embedded_dock = None
+ else:
+ widget.hide()
+
+
+
+[docs]
+ @QtCore.Slot()
+ def hide_subdisplays(self):
+ """Hide all open displays."""
+ # Grab children from devices
+ for group in self.top_level_groups.values():
+ for param in flatten_tree(group)[1:]:
+ self.hide_subdisplay(param)
+
+
+ @property
+ def tools(self):
+ """Tools loaded into the suite."""
+ if 'Tools' in self.top_level_groups:
+ return [param.value()
+ for param in self.top_level_groups['Tools'].childs]
+ return []
+
+ def _update_title(self, device=None):
+ """
+ Update the window title, optionally with a device.
+
+ Parameters
+ ----------
+ device : ophyd.Device, optional
+ Device to indicate in the title.
+ """
+ title_fmt = (self.DEFAULT_TITLE if device is None
+ else self.DEFAULT_TITLE_DEVICE)
+
+ self.setWindowTitle(title_fmt.format(self=self, device=device))
+
+
+[docs]
+ def add_device(self, device, children=True, category='Devices'):
+ """
+ Add a device to the suite.
+
+ Parameters
+ ----------
+ device: ophyd.Device
+ The device to add.
+
+ children: bool, optional
+ Also add any ``subdevices`` of this device to the suite as well.
+
+ category: str, optional
+ Category of device. By default, all devices will just be added to
+ the "Devices" group
+ """
+
+ super().add_device(device)
+ self._update_title(device)
+ # Create DeviceParameter and add to top level category
+ dev_param = DeviceParameter(device, subdevices=children)
+ self._add_to_sidebar(dev_param, category)
+ # Grab children
+ for child in flatten_tree(dev_param)[1:]:
+ self._add_to_sidebar(child)
+ # Add a device to all the tool displays
+ for tool in self.tools:
+ try:
+ tool.add_device(device)
+ except Exception:
+ logger.exception("Unable to add %s to tool %s",
+ device.name, type(tool))
+
+
+
+[docs]
+ @classmethod
+ def from_device(
+ cls,
+ device: Device,
+ parent: QtWidgets.QWidget | None = None,
+ tools: dict[str, type] | None | DEFAULT_TOOLS = DEFAULT_TOOLS,
+ pin: bool = False,
+ content_layout: QtWidgets.QLayout | None = None,
+ default_display_type: DisplayTypes = DisplayTypes.detailed_screen,
+ scroll_option: ScrollOptions = ScrollOptions.auto,
+ show_displays: bool = True,
+ **kwargs,
+ ) -> TyphosSuite:
+ """
+ Create a new :class:`TyphosSuite` from an :class:`ophyd.Device`.
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ The device to use.
+
+ children : bool, optional
+ Choice to include child Device components
+
+ parent : QWidget
+
+ tools : dict, optional
+ Tools to load for the object. ``dict`` should be name, class pairs.
+ By default these will be ``.default_tools``, but ``None`` can be
+ passed to avoid tool loading completely.
+
+ pin : bool, optional
+ Pin the parameter tree on startup.
+
+ content_layout : QLayout, optional
+ Sets the layout for when we have multiple subdisplays
+ open in the suite. This will have a horizontal layout by
+ default but can be changed as needed for the use case.
+
+ default_display_type : DisplayTypes, optional
+ DisplayTypes enum that determines the type of display to open when
+ we add a device to the suite. Defaults to
+ DisplayTypes.detailed_screen.
+
+ scroll_option : ScrollOptions, optional
+ ScrollOptions enum that determines the behavior of scrollbars
+ in the suite. Default is ScrollOptions.auto, which enables
+ scrollbars for detailed and engineering screens but not for
+ embedded displays.
+
+ show_displays : bool, optional
+ If True (default), open all the included device displays.
+ If False, do not open any of the displays.
+
+ **kwargs :
+ Passed to :meth:`TyphosSuite.add_device`
+ """
+ return cls.from_devices([device], parent=parent, tools=tools, pin=pin,
+ content_layout=content_layout,
+ default_display_type=default_display_type,
+ scroll_option=scroll_option,
+ show_displays=show_displays,
+ **kwargs)
+
+
+
+[docs]
+ @classmethod
+ def from_devices(
+ cls,
+ devices: list[Device],
+ parent: QtWidgets.QWidget | None = None,
+ tools: dict[str, type] | None | DEFAULT_TOOLS = DEFAULT_TOOLS,
+ pin: bool = False,
+ content_layout: QtWidgets.QLayout | None = None,
+ default_display_type: DisplayTypes = DisplayTypes.detailed_screen,
+ scroll_option: ScrollOptions = ScrollOptions.auto,
+ show_displays: bool = True,
+ **kwargs,
+ ) -> TyphosSuite:
+ """
+ Create a new TyphosSuite from an iterator of :class:`ophyd.Device`
+
+ Parameters
+ ----------
+ device : ophyd.Device
+
+ children : bool, optional
+ Choice to include child Device components
+
+ parent : QWidget
+
+ tools : dict, optional
+ Tools to load for the object. ``dict`` should be name, class pairs.
+ By default these will be ``.default_tools``, but ``None`` can be
+ passed to avoid tool loading completely.
+
+ pin : bool, optional
+ Pin the parameter tree on startup.
+
+ content_layout : QLayout, optional
+ Sets the layout for when we have multiple subdisplays
+ open in the suite. This will have a horizontal layout by
+ default but can be changed as needed for the use case.
+
+ default_display_type : DisplayTypes, optional
+ DisplayTypes enum that determines the type of display to open when
+ we add a device to the suite. Defaults to
+ DisplayTypes.detailed_screen.
+
+ scroll_option : ScrollOptions, optional
+ ScrollOptions enum that determines the behavior of scrollbars
+ in the suite. Default is ScrollOptions.auto, which enables
+ scrollbars for detailed and engineering screens but not for
+ embedded displays.
+
+ show_displays : bool, optional
+ If True (default), open all the included device displays.
+ If False, do not open any of the displays.
+
+ **kwargs :
+ Passed to :meth:`TyphosSuite.add_device`
+ """
+ suite = cls(
+ parent=parent,
+ pin=pin,
+ content_layout=content_layout,
+ default_display_type=default_display_type,
+ scroll_option=scroll_option,
+ )
+ if tools is not None:
+ logger.info("Loading Tools ...")
+ if tools is DEFAULT_TOOLS:
+ logger.debug("Using default TyphosSuite tools ...")
+ tools = cls.default_tools
+ for name, tool in tools.items():
+ try:
+ suite.add_tool(name, tool)
+ except Exception:
+ logger.exception("Unable to load %s", type(tool))
+
+ logger.info("Adding devices ...")
+ for device in devices:
+ try:
+ suite.add_device(device, **kwargs)
+ if show_displays:
+ suite.show_subdisplay(device)
+ except Exception:
+ logger.exception("Unable to add %r to TyphosSuite",
+ device.name)
+ return suite
+
+
+
+[docs]
+ def save(self):
+ """
+ Save suite settings to a file using :meth:`typhos.utils.save_suite`.
+
+ A ``QFileDialog`` will be used to query the user for the desired
+ location of the created Python file
+
+ The template will be of the form:
+
+ .. code::
+ """
+ # Note: the above docstring is appended below
+
+ logger.debug("Requesting file location for saved TyphosSuite")
+ root_dir = os.getcwd()
+ filename = QtWidgets.QFileDialog.getSaveFileName(
+ self, 'Save TyphosSuite', root_dir, "Python (*.py)")
+ if filename:
+ try:
+ with open(filename[0], 'w+') as handle:
+ save_suite(self, handle)
+ except Exception as exc:
+ logger.exception("Failed to save TyphosSuite")
+ utils.raise_to_operator(exc)
+ else:
+ logger.debug("No filename chosen")
+
+
+ # Add the template to the docstring
+ save.__doc__ += textwrap.indent('\n' + utils.saved_template, '\t\t')
+
+
+[docs]
+ def save_screenshot(
+ self,
+ filename: str,
+ ) -> bool:
+ """Save a screenshot of this widget to ``filename``."""
+
+ image = utils.take_widget_screenshot(self)
+ if image is None:
+ logger.warning("Failed to take screenshot")
+ return False
+
+ logger.info(
+ "Saving screenshot of suite titled '%s' to '%s'",
+ self.windowTitle(), filename,
+ )
+ image.save(filename)
+ return True
+
+
+
+[docs]
+ def save_device_screenshots(
+ self,
+ filename_format: str,
+ ) -> dict[str, str]:
+ """Save screenshot(s) of devices to ``filename_format``."""
+
+ filenames = {}
+ for device in self.devices:
+ display = self.get_subdisplay(device)
+
+ if hasattr(display, "to_image"):
+ image = display.to_image()
+ else:
+ # This is a fallback for if/when we don't have a TyphosDisplay
+ image = utils.take_widget_screenshot(display)
+
+ suite_title = self.windowTitle()
+ widget_title = display.windowTitle()
+ if image is None:
+ logger.warning(
+ "Failed to take screenshot of device: %s in %s",
+ device.name, suite_title,
+ )
+ continue
+
+ filename = filename_format.format(
+ suite_title=suite_title,
+ widget_title=widget_title,
+ device=device,
+ name=device.name,
+ )
+ logger.info(
+ "Saving screenshot of '%s': '%s' to '%s'",
+ suite_title, widget_title, filename,
+ )
+ image.save(filename)
+ filenames[device.name] = filename
+ return filenames
+
+
+ def _get_sidebar(self, widget):
+ items = {}
+ for group in self.top_level_groups.values():
+ for item in flatten_tree(group):
+ items[item.value()] = item
+ return items.get(widget)
+
+ def _show_sidebar(self, widget, dock):
+ sidebar = self._get_sidebar(widget)
+ if sidebar:
+ for item in sidebar.items:
+ item._mark_shown()
+ # Make sure we react if the dock is closed outside of our menu
+ self._connect_partial_weakly(
+ dock, dock.closing, self.hide_subdisplay, sidebar
+ )
+ else:
+ logger.warning("Unable to find sidebar item for %r", widget)
+
+ def _add_to_sidebar(self, parameter, category=None):
+ """Add an item to the sidebar, connecting necessary signals."""
+ if category:
+ # Create or grab our category
+ if category in self.top_level_groups:
+ group = self.top_level_groups[category]
+ else:
+ logger.debug("Creating new category %r ...", category)
+ group = ptypes.GroupParameter(name=category)
+ self._tree.addParameters(group)
+ self._tree.sortItems(0, QtCore.Qt.AscendingOrder)
+ logger.debug("Adding %r to category %r ...",
+ parameter.name(), group.name())
+ group.addChild(parameter)
+
+ widget = parameter.value()
+ if isinstance(widget, QtWidgets.QWidget):
+ # Setup window to have a parent
+ widget.setParent(self)
+ widget.setHidden(True)
+
+ logger.debug("Connecting parameter signals ...")
+ self._connect_partial_weakly(
+ parameter, parameter.sigOpen, self.show_subdisplay, parameter
+ )
+ self._connect_partial_weakly(
+ parameter, parameter.sigHide, self.hide_subdisplay, parameter
+ )
+ if parameter.embeddable:
+ self._connect_partial_weakly(
+ parameter, parameter.sigEmbed, self.embed_subdisplay, parameter
+ )
+ return parameter
+
+
+"""
+Multiline text edit widget.
+
+Variety support pending:
+- Text format
+"""
+import logging
+
+import numpy as np
+from pydm.widgets.base import PyDMWritableWidget
+from qtpy import QtWidgets
+
+from . import variety
+
+logger = logging.getLogger(__name__)
+
+
+
+[docs]
+@variety.uses_key_handlers
+@variety.use_for_variety_write('text-multiline')
+class TyphosTextEdit(QtWidgets.QWidget, PyDMWritableWidget):
+ """
+ A writable, multiline text editor with support for PyDM Channels.
+
+ Parameters
+ ----------
+ parent : QWidget
+ The parent widget.
+
+ init_channel : str, optional
+ The channel to be used by the widget.
+ """
+
+ def __init__(self, parent=None, init_channel=None, variety_metadata=None,
+ ophyd_signal=None):
+
+ self._display_text = None
+ self._encoding = "utf-8"
+ self._delimiter = '\n'
+ self._ophyd_signal = ophyd_signal
+ self._format = 'plain'
+ self._raw_value = None
+
+ QtWidgets.QWidget.__init__(self, parent)
+ PyDMWritableWidget.__init__(self, init_channel=init_channel)
+ # superclasses do *not* support cooperative init:
+ # super().__init__(self, parent=parent, init_channel=init_channel)
+
+ self._setup_ui()
+ self.variety_metadata = variety_metadata
+
+ def _setup_ui(self):
+ layout = QtWidgets.QVBoxLayout()
+ self.setLayout(layout)
+
+ self._text_edit = QtWidgets.QTextEdit()
+ self._send_button = QtWidgets.QPushButton('Send')
+ self._send_button.clicked.connect(self._send_clicked)
+
+ self._revert_button = QtWidgets.QPushButton('Revert')
+ self._revert_button.clicked.connect(self._revert_clicked)
+
+ self._button_layout = QtWidgets.QHBoxLayout()
+
+ self._button_layout.addWidget(self._revert_button)
+ self._button_layout.addWidget(self._send_button)
+
+ layout.addWidget(self._text_edit)
+ layout.addLayout(self._button_layout)
+
+ def _revert_clicked(self):
+ self._set_text(self._display_text)
+
+ def _send_clicked(self):
+ self.send_value()
+
+
+[docs]
+ def value_changed(self, value):
+ """Receive and update the TyphosTextEdit for a new channel value."""
+ self._raw_value = value
+ super().value_changed(self._from_wire(value))
+ self.set_display()
+
+
+ def _to_wire(self, text=None):
+ """TextEdit text -> numpy array."""
+ if text is None:
+ # text-format: toMarkdown, toHtml
+ text = self._text_edit.toPlainText()
+
+ text = self._delimiter.join(text.splitlines())
+ return np.array(list(text.encode(self._encoding)),
+ dtype=np.uint8)
+
+ def _from_wire(self, value):
+ """numpy array/string/bytes -> string."""
+ if isinstance(value, (list, np.ndarray)):
+ return bytes(value).decode(self._encoding)
+ return value
+
+ def _set_text(self, text):
+ return self._text_edit.setText(text)
+
+
+[docs]
+ def send_value(self):
+ """Emit a :attr:`send_value_signal` to update channel value."""
+ send_value = self._to_wire()
+
+ try:
+ self.send_value_signal[np.ndarray].emit(send_value)
+ except ValueError:
+ logger.exception(
+ "send_value error %r with type %r and format %r (widget %r).",
+ send_value, self.channeltype, self._display_format_type,
+ self.objectName()
+ )
+
+ self._text_edit.document().setModified(False)
+
+
+
+[docs]
+ def write_access_changed(self, new_write_access):
+ """
+ Change the TyphosTextEdit to read only if write access is denied
+ """
+ super().write_access_changed(new_write_access)
+ self._text_edit.setReadOnly(not new_write_access)
+ self._send_button.setVisible(new_write_access)
+ self._revert_button.setVisible(new_write_access)
+
+
+
+[docs]
+ def set_display(self):
+ """Set the text display of the TyphosTextEdit."""
+ if self.value is None or self._text_edit.document().isModified():
+ return
+
+ self._display_text = str(self.value)
+ self._set_text(self._display_text)
+
+
+ variety_metadata = variety.create_variety_property()
+
+ def _reinterpret_text(self):
+ """Re-interpret the raw value, if formatting and such change."""
+ if self._raw_value is not None:
+ self.value_changed(self._raw_value)
+
+ @variety.key_handler('delimiter')
+ def _variety_key_handler_delimiter(self, delimiter):
+ self._delimiter = delimiter
+
+ @variety.key_handler('encoding')
+ def _variety_key_handler_encoding(self, encoding):
+ self._encoding = encoding
+ self._reinterpret_text()
+
+ @variety.key_handler('format')
+ def _variety_key_handler_format(self, format_):
+ self._format = format_
+ if format_ != 'plain':
+ logger.warning('Non-plain formats not yet implemented.')
+ self._reinterpret_text()
+
+
+import logging
+
+from pydm.widgets.logdisplay import PyDMLogDisplay
+from qtpy.QtWidgets import QVBoxLayout
+
+from ..utils import TyphosBase
+
+
+
+[docs]
+class TyphosLogDisplay(TyphosBase):
+ """Typhos Logging Display."""
+
+[docs]
+ def __init__(self, level=logging.INFO, parent=None):
+ super().__init__(parent=parent)
+ # Set the logname to be non-existant so that we do not attach to the
+ # root logger. This causes issue if this widget is closed before the
+ # end of the Python session. For the long term this issue will be
+ # resolved with https://github.com/slaclab/pydm/issues/474
+ self.logdisplay = PyDMLogDisplay(logname='not_set', level=level,
+ parent=self)
+ self.setLayout(QVBoxLayout())
+ self.layout().addWidget(self.logdisplay)
+
+
+ def add_device(self, device):
+ """Add a device to the logging display."""
+ super().add_device(device)
+ # If this is the first device
+ if len(self.devices) == 1:
+ self.logdisplay.logName = device.log.name
+ # If we have already attached a device, just set it to NOTSET to let
+ # the existing handler do all the filtering
+ else:
+ device.log.setLevel(logging.NOTSET)
+ logger = getattr(device.log, 'logger', device.log)
+ logger.addHandler(self.logdisplay.handler)
+
+
+"""
+Typhos Plotting Interface
+"""
+import logging
+
+from qtpy import QtCore, QtGui
+from qtpy.QtCore import Qt, Slot
+from qtpy.QtWidgets import (QComboBox, QHBoxLayout, QLabel, QPushButton,
+ QVBoxLayout)
+from timechart.displays.main_display import TimeChartDisplay
+from timechart.utilities.utils import random_color
+
+from .. import utils
+from ..cache import get_global_describe_cache
+
+logger = logging.getLogger(__name__)
+
+
+
+[docs]
+class TyphosTimePlot(utils.TyphosBase):
+ """
+ Generalized widget for plotting Ophyd signals.
+
+ This widget is a ``TimeChartDisplay`` wrapped with some convenient
+ functions for adding signals by their attribute name.
+
+ Parameters
+ ----------
+ parent : QWidget
+ """
+
+
+[docs]
+ def __init__(self, parent=None):
+ super().__init__(parent=parent)
+ # Setup layout
+ self.setLayout(QVBoxLayout())
+ self.layout().setContentsMargins(2, 2, 2, 2)
+
+ self._model = QtGui.QStandardItemModel()
+ self._proxy_model = QtCore.QSortFilterProxyModel()
+ self._proxy_model.setSourceModel(self._model)
+
+ self._available_signals = {}
+
+ self.signal_combo = QComboBox()
+ self.signal_combo.setModel(self._proxy_model)
+ self.signal_combo_label = QLabel('Available Signals: ')
+
+ self.signal_create = QPushButton('Connect')
+ self.signal_combo_layout = QHBoxLayout()
+ self.signal_combo_layout.addWidget(self.signal_combo_label, 0)
+ self.signal_combo_layout.addWidget(self.signal_combo, 1)
+ self.signal_combo_layout.addWidget(self.signal_create, 0)
+ self.signal_create.clicked.connect(self.creation_requested)
+ self.layout().addLayout(self.signal_combo_layout)
+ # Add timechart
+ self.timechart = TimeChartDisplay(show_pv_add_panel=False)
+ self.layout().addWidget(self.timechart)
+ cache = get_global_describe_cache()
+ cache.new_description.connect(self._new_description,
+ Qt.QueuedConnection)
+
+
+ @property
+ def channel_to_curve(self):
+ """
+ A dictionary of channel_name to curve.
+ """
+ return dict(self.timechart.channel_map)
+
+ def add_available_signal(self, signal, name):
+ """
+ Add an Ophyd signal to the list of available channels.
+
+ If the Signal is not an EPICS Signal object you are responsible for
+ registering this yourself, if not already done.
+
+ Parameters
+ ----------
+ signal : ophyd.Signal
+
+ name : str
+ Alias for signal to display in QComboBox.
+
+ Raises
+ ------
+ ValueError
+ If a signal of the same name already is available.
+ """
+ if name in self._available_signals:
+ raise ValueError('Signal already available')
+
+ channel = utils.channel_from_signal(signal)
+ self._available_signals[name] = (signal, channel)
+ item = QtGui.QStandardItem(name)
+ item.setData(channel, Qt.UserRole)
+ self._model.appendRow(item)
+ self._model.sort(0)
+
+ def add_curve(self, channel, name=None, color=None, **kwargs):
+ """
+ Add a curve to the plot.
+
+ Parameters
+ ----------
+ channel : str
+ PyDMChannel address.
+
+ name : str, optional
+ Name of TimePlotCurveItem. If None is given, the ``channel`` is
+ used.
+
+ color : QColor, optional
+ Color to display line in plot. If None is given, a QColor will be
+ chosen randomly.
+
+ **kwargs
+ Passed to :meth:`timechart.add_y_channel`.
+ """
+ name = name or channel
+ # Create a random color if None is supplied
+ if not color:
+ color = random_color()
+ logger.debug("Adding %s to plot ...", channel)
+ self.timechart.add_y_channel(pv_name=channel, curve_name=name,
+ color=color, **kwargs)
+
+ @Slot()
+ def remove_curve(self, name):
+ """
+ Remove a curve from the plot.
+
+ Parameters
+ ----------
+ name : str
+ Name of the curve to remove. This should match the name given
+ during the call of :meth:`.add_curve`.
+ """
+ logger.debug("Removing %s from TyphosTimePlot ...", name)
+ self.timechart.remove_curve(name)
+
+ @Slot()
+ def creation_requested(self):
+ """
+ Reaction to ``create_button`` press.
+
+ Observes the state of the selection widgets and makes the appropriate
+ call to :meth:`.add_curve`.
+ """
+ # Find requested channel
+ name = self.signal_combo.currentText()
+ idx = self.signal_combo.currentIndex()
+ channel = self.signal_combo.itemData(idx)
+ # Add to the plot
+ self.add_curve(channel, name=name)
+
+ @Slot(object, dict)
+ def _new_description(self, signal, desc):
+ name = f'{signal.root.name}.{signal.dotted_name}'
+ if 'dtype' not in desc:
+ # Marks an error in retrieving the description
+ logger.debug("Ignoring signal without description %s", name)
+ return
+
+ # Only include scalars
+ if desc['dtype'] not in ('integer', 'number'):
+ logger.debug("Ignoring non-scalar signal %s", name)
+ return
+
+ # Add to list of available signal
+ try:
+ self.add_available_signal(signal, name)
+ except ValueError:
+ # Signal already added
+ return
+
+ def add_device(self, device):
+ """Add a device and it's component signals to the plot."""
+ super().add_device(device)
+
+ cache = get_global_describe_cache()
+ for signal in utils.get_all_signals_from_device(device,
+ include_lazy=False):
+ desc = cache.get(signal)
+ if desc is not None:
+ self._new_description(signal, desc)
+
+
+"""
+Tweakable value widget.
+
+Variety support pending:
+- everything
+"""
+import logging
+
+import qtpy
+from qtpy import QtCore
+
+from . import utils, variety
+
+logger = logging.getLogger(__name__)
+
+
+
+[docs]
+@variety.uses_key_handlers
+@variety.use_for_variety_write('scalar-tweakable')
+class TyphosTweakable(utils.TyphosBase):
+ # TODO rearrange package: widgets.TyphosDesignerMixin):
+ """
+ Widget for a tweakable scalar.
+
+ Parameters
+ ----------
+ parent : QWidget
+ The parent widget.
+
+ init_channel : str, optional
+ The channel to be used by the widget.
+
+ Notes
+ -----
+ """
+
+ ui_template = utils.ui_dir / 'widgets' / 'tweakable.ui'
+ _readback_attr = 'readback'
+ _setpoint_attr = 'setpoint'
+
+ def __init__(self, parent=None, init_channel=None, variety_metadata=None,
+ ophyd_signal=None):
+
+ self._ophyd_signal = ophyd_signal
+ super().__init__(parent=parent)
+
+ self.ui = qtpy.uic.loadUi(str(self.ui_template), self)
+ self.ui.readback.channel = init_channel
+ self.ui.setpoint.channel = init_channel
+ self.ui.tweak_positive.clicked.connect(self.positive_tweak)
+ self.ui.tweak_negative.clicked.connect(self.negative_tweak)
+
+ self.variety_metadata = variety_metadata
+
+ variety_metadata = variety.create_variety_property()
+
+ def _update_variety_metadata(self, *, display_format=None, **kwargs):
+ display_format = variety.get_display_format(display_format)
+ self.ui.readback.displayFormat = display_format
+ self.ui.setpoint.displayFormat = display_format
+
+ variety._warn_unhandled_kwargs(self, kwargs)
+
+
+[docs]
+ def tweak(self, offset):
+ """Tweak by the given ``offset``."""
+ try:
+ setpoint = float(self.readback.text()) + float(offset)
+ except Exception:
+ logger.exception('Tweak failed')
+ return
+
+ self.ui.setpoint.setText(str(setpoint))
+ self.ui.setpoint.send_value()
+
+
+
+[docs]
+ @QtCore.Slot()
+ def positive_tweak(self):
+ """Tweak positive by the amount listed in ``ui.tweak_value``"""
+ try:
+ self.tweak(float(self.tweak_value.text()))
+ except Exception:
+ logger.exception('Tweak failed')
+
+
+
+[docs]
+ @QtCore.Slot()
+ def negative_tweak(self):
+ """Tweak negative by the amount listed in ``ui.tweak_value``"""
+ try:
+ self.tweak(-float(self.tweak_value.text()))
+ except Exception:
+ logger.exception('Tweak failed')
+
+
+
+"""
+Utility functions for typhos
+"""
+from __future__ import annotations
+
+import atexit
+import collections
+import contextlib
+import functools
+import importlib.util
+import inspect
+import io
+import json
+import logging
+import operator
+import os
+import pathlib
+import random
+import re
+import threading
+import weakref
+from types import MethodType
+from typing import Dict, Generator, Iterable, Optional
+
+import entrypoints
+import ophyd
+import ophyd.sim
+import pydm
+from ophyd import Device
+from ophyd.signal import EpicsSignalBase, EpicsSignalRO
+from pydm.config import STYLESHEET as PYDM_USER_STYLESHEET
+from pydm.config import STYLESHEET_INCLUDE_DEFAULT as PYDM_INCLUDE_DEFAULT
+from pydm.exception import raise_to_operator # noqa
+from pydm.utilities.stylesheet import \
+ GLOBAL_STYLESHEET as PYDM_DEFAULT_STYLESHEET
+from pydm.widgets.base import PyDMWritableWidget
+from qtpy import QtCore, QtGui, QtWidgets
+from qtpy.QtCore import QSize
+from qtpy.QtGui import QColor, QMovie, QPainter
+from qtpy.QtWidgets import QWidget
+
+from . import plugins
+
+try:
+ import happi
+except ImportError:
+ happi = None
+
+logger = logging.getLogger(__name__)
+
+# Entry point for directories of custom widgets
+# Must be one of:
+# - str
+# - pathlib.Path
+# - list of such objects
+TYPHOS_ENTRY_POINT_KEY = 'typhos.ui'
+MODULE_PATH = pathlib.Path(__file__).parent.resolve()
+ui_dir = MODULE_PATH / 'ui'
+ui_core_dir = ui_dir / 'core'
+
+GrabKindItem = collections.namedtuple('GrabKindItem',
+ ('attr', 'component', 'signal'))
+DEBUG_MODE = bool(os.environ.get('TYPHOS_DEBUG', False))
+
+# Help settings:
+# TYPHOS_HELP_URL (str): The help URL format string
+HELP_URL = os.environ.get('TYPHOS_HELP_URL', "").strip()
+HELP_WEB_ENABLED = bool(HELP_URL.strip())
+
+# TYPHOS_HELP_HEADERS (json): headers to pass to HELP_URL
+HELP_HEADERS = json.loads(os.environ.get('TYPHOS_HELP_HEADERS', "") or "{}")
+HELP_HEADERS_HOSTS = os.environ.get("TYPHOS_HELP_HEADERS_HOSTS", "").split(",")
+
+# TYPHOS_HELP_TOKEN (str): An optional token for the bearer authentication
+# scheme - e.g., personal access tokens with Confluence
+HELP_TOKEN = os.environ.get('TYPHOS_HELP_TOKEN', None)
+if HELP_TOKEN:
+ HELP_HEADERS["Authorization"] = f"Bearer {HELP_TOKEN}"
+
+# TYPHOS_JIRA_URL (str): The jira REST API collector URL
+JIRA_URL = os.environ.get('TYPHOS_JIRA_URL', "").strip()
+# TYPHOS_JIRA_HEADERS (json): headers to pass to JIRA_URL
+JIRA_HEADERS = json.loads(
+ os.environ.get('TYPHOS_JIRA_HEADERS', '{"X-Atlassian-Token": "no-check"}')
+ or "{}"
+)
+# TYPHOS_JIRA_TOKEN (str): An optional token for the bearer authentication
+# scheme - e.g., personal access tokens with Confluence
+JIRA_TOKEN = os.environ.get('TYPHOS_JIRA_TOKEN', None)
+# TYPHOS_JIRA_EMAIL_SUFFIX (str): The default e-mail address suffix
+JIRA_EMAIL_SUFFIX = os.environ.get('TYPHOS_JIRA_EMAIL_SUFFIX', "").strip()
+if JIRA_TOKEN:
+ JIRA_HEADERS["Authorization"] = f"Bearer {JIRA_TOKEN}"
+
+if happi is None:
+ logger.info("happi is not installed; some features may be unavailable")
+
+
+
+
+
+
+def _get_display_paths():
+ """
+ Get all display paths.
+
+ This includes, in order:
+
+ - The $PYDM_DISPLAYS_PATH environment variable
+ - The typhos.ui entry point
+ - typhos built-ins
+ """
+ paths = os.environ.get('PYDM_DISPLAYS_PATH', '')
+ for path in paths.split(os.pathsep):
+ path = pathlib.Path(path).expanduser().resolve()
+ if path.exists() and path.is_dir():
+ yield path
+
+ _entries = entrypoints.get_group_all(TYPHOS_ENTRY_POINT_KEY)
+ entry_objs = []
+
+ for entry in _entries:
+ try:
+ obj = entry.load()
+ except Exception:
+ msg = (f'Failed to load {TYPHOS_ENTRY_POINT_KEY} '
+ f'entry: {entry.name}.')
+ logger.error(msg)
+ logger.debug(msg, exc_info=True)
+ continue
+ if isinstance(obj, list):
+ entry_objs.extend(obj)
+ else:
+ entry_objs.append(obj)
+
+ for obj in entry_objs:
+ try:
+ yield pathlib.Path(obj)
+ except Exception:
+ msg = (f'{TYPHOS_ENTRY_POINT_KEY} entry point '
+ f'{entry.name}: {obj} is not a valid path!')
+ logger.error(msg)
+ logger.debug(msg, exc_info=True)
+
+ yield ui_dir / 'core'
+ yield ui_dir / 'devices'
+
+
+DISPLAY_PATHS = list(_get_display_paths())
+
+
+if hasattr(ophyd.signal, 'SignalRO'):
+ SignalRO = ophyd.signal.SignalRO
+else:
+ # SignalRO was re-introduced to ophyd.signal in December 2019 (1f83a055).
+ # If unavailable, fall back to our previous definition:
+ class SignalRO(ophyd.sim.SynSignalRO):
+ def __init__(self, value=0, *args, **kwargs):
+ self._value = value
+ super().__init__(*args, **kwargs)
+ self._metadata.update(
+ connected=True,
+ write_access=False,
+ )
+
+ def get(self):
+ return self._value
+
+
+
+[docs]
+def channel_from_signal(signal, read=True):
+ """
+ Create a PyDM address from arbitrary signal type
+ """
+ if isinstance(signal, EpicsSignalBase):
+ if read:
+ # For readback widgets, focus on the _read_pv only:
+ attrs = ["_read_pv"]
+ else:
+ # For setpoint widgets, _write_pv may exist (and differ) from
+ # _read_pv, try it first:
+ attrs = ["_write_pv", "_read_pv"]
+
+ # Some customizations of EpicsSignalBase may have different attributes.
+ # Try the attributes, but don't fail if they are not present:
+ for attr in attrs:
+ pv_instance = getattr(signal, attr, None)
+ pvname = getattr(pv_instance, "pvname", None)
+ if pvname is not None and isinstance(pvname, str):
+ return channel_name(pvname)
+
+ return channel_name(signal.name, protocol='sig')
+
+
+
+
+[docs]
+def is_signal_ro(signal):
+ """
+ Return whether the signal is read-only, based on its class.
+
+ In the future this may be easier to do through improvements to
+ introspection in the ophyd library. Until that day we need to check classes
+ """
+ return isinstance(signal, (SignalRO, EpicsSignalRO, ophyd.sim.SynSignalRO))
+
+
+
+
+[docs]
+def channel_name(pv, protocol='ca'):
+ """
+ Create a valid PyDM channel from a PV name
+ """
+ return protocol + '://' + pv
+
+
+
+
+[docs]
+def clean_attr(attr):
+ """
+ Create a nicer, human readable alias from a Python attribute name
+ """
+ return attr.replace('.', ' ').replace('_', ' ')
+
+
+
+
+[docs]
+def clean_name(device, strip_parent=True):
+ """
+ Create a human readable name for a device
+
+ Parameters
+ ----------
+ device: ophyd.Device
+
+ strip_parent: bool or Device
+ Remove the parent name of the device from name. If strip_parent is
+ True, the name of the direct parent of the device is stripped. If a
+ device is provided the name of that device is used. This allows
+ specification for removal at any point of the device schema
+ """
+ name = device.name
+ if strip_parent and device.parent:
+ if isinstance(strip_parent, Device):
+ parent_name = strip_parent.name
+ else:
+ parent_name = device.parent.name
+ name = name.replace(parent_name + '_', '')
+ # Return the cleaned alias
+ return clean_attr(name)
+
+
+
+
+[docs]
+def use_stylesheet(
+ dark: bool = False,
+ widget: QtWidgets.QWidget | None = None,
+) -> None:
+ """
+ Use the Typhos stylesheet
+
+ This is no longer used directly in typhos in favor of
+ apply_standard_stylesheets.
+
+ This can still be used if you want the legacy behavior of ignoring PyDM
+ environment variables. The function is unchanged.
+
+ Parameters
+ ----------
+ dark: bool, optional
+ Whether or not to use the QDarkStyleSheet theme. By default the light
+ theme is chosen.
+ """
+ # Dark Style
+ if dark:
+ import qdarkstyle
+ style = qdarkstyle.load_stylesheet_pyqt5()
+ # Light Style
+ else:
+ # Load the path to the file
+ style_path = os.path.join(ui_dir, 'style.qss')
+ if not os.path.exists(style_path):
+ raise OSError("Unable to find Typhos stylesheet in {}"
+ "".format(style_path))
+ # Load the stylesheet from the file
+ with open(style_path) as handle:
+ style = handle.read()
+ if widget is None:
+ widget = QtWidgets.QApplication.instance()
+ # We can set Fusion style if it is an application
+ if isinstance(widget, QtWidgets.QApplication):
+ widget.setStyle(QtWidgets.QStyleFactory.create('Fusion'))
+
+ # Set Stylesheet
+ widget.setStyleSheet(style)
+
+
+
+
+[docs]
+def compose_stylesheets(stylesheets: Iterable[str | pathlib.Path]) -> str:
+ """
+ Combine multiple qss stylesheets into one qss stylesheet.
+
+ If two stylesheets make conflicting specifications, the one passed into
+ this function first will take priority.
+
+ This is accomplished by placing the text from the highest-priority
+ stylesheets at the bottom of the combined stylesheet. Stylesheets are
+ evaluated in order from top to bottom, and newer elements on the bottom
+ will override elements at the top.
+
+ Parameters
+ ----------
+ stylesheets : iterable of str or pathlib.Path
+ An itetable, such as a list, of the stylesheets to combine.
+ Each element can either be a fully-loaded stylesheet or a full path to
+ a stylesheet. Stylesheet paths must end in the .qss suffix.
+ In the unlikely event that a string is both a valid path
+ and a valid stylesheet, it will be interpretted as a path,
+ even if no file exists at that path.
+
+ Returns
+ -------
+ composed_style : str
+ A string suitable for passing into QWidget.setStylesheet that
+ incorporates all of the input stylesheets.
+
+ Raises
+ ------
+ OSError
+ If any error is encountered while reading a file
+ TypeError
+ If the input is not a valid type
+ """
+ style_parts = []
+ for sheet in stylesheets:
+ path = pathlib.Path(sheet)
+ if isinstance(sheet, pathlib.Path) or path.suffix == ".qss":
+ with path.open() as fd:
+ style_parts.append(fd.read())
+ elif isinstance(sheet, str):
+ style_parts.append(sheet)
+ else:
+ raise TypeError(f"Invalid input {sheet} of type {type(sheet)}")
+ return "\n".join(reversed(style_parts))
+
+
+
+
+[docs]
+def apply_standard_stylesheets(
+ dark: bool = False,
+ paths: Iterable[str] | None = None,
+ include_pydm: bool = True,
+ widget: QtWidgets.QWidget | None = None,
+) -> None:
+ """
+ Apply all the stylesheets at once, along with the Fusion style.
+
+ Applies stylesheets with the following priority order:
+ - Any existing stylesheet data on the widget
+ - User stylesheets in the paths argument
+ - User stylesheets in PYDM_STYLESHEET (which behaves as a path)
+ - Typhos's stylesheet (either the dark or the light variant)
+ - PyDM's built-in stylesheet, if PYDM_STYLESHEET_INCLUDE_DEFAULT is set.
+
+ The Fusion style can only be applied to a QApplication.
+
+ Parameters
+ ----------
+ dark : bool, optional
+ Whether or not to use the QDarkStyleSheet theme. By default the light
+ theme is chosen.
+ paths : iterable of str, optional
+ User-provided paths to stylesheets to apply.
+ include_pydm : bool, optional
+ Whether or not to use the stylesheets defined in the pydm environment
+ variables. Defaults to True.
+ widget : QWidget, optional
+ The widget to apply the stylesheet to.
+ If omitted, apply to the whole QApplication.
+ """
+ if isinstance(widget, QtWidgets.QApplication):
+ widget.setStyle(QtWidgets.QStyleFactory.create('Fusion'))
+
+ stylesheets = []
+
+ if widget is None:
+ widget = QtWidgets.QApplication.instance()
+ stylesheets.append(widget.styleSheet())
+
+ if paths is not None:
+ stylesheets.extend(paths)
+
+ if include_pydm and PYDM_USER_STYLESHEET:
+ stylesheets.extend(PYDM_USER_STYLESHEET.split(os.pathsep))
+
+ if dark:
+ import qdarkstyle
+ stylesheets.append(qdarkstyle.load_stylesheet_pyqt5())
+ else:
+ stylesheets.append(ui_dir / 'style.qss')
+
+ if include_pydm and PYDM_INCLUDE_DEFAULT:
+ stylesheets.append(PYDM_DEFAULT_STYLESHEET)
+
+ widget.setStyleSheet(compose_stylesheets(stylesheets))
+
+
+
+
+[docs]
+def random_color():
+ """Return a random hex color description"""
+ return QColor(random.randint(0, 255),
+ random.randint(0, 255),
+ random.randint(0, 255))
+
+
+
+
+[docs]
+class TyphosLoading(QtWidgets.QLabel):
+ """
+ A QLabel with an animation for loading status.
+
+ Attributes
+ ----------
+ LOADING_TIMEOUT_MS : int
+ The timeout value in milliseconds for when to stop the animation
+ and replace it with a default timeout message.
+
+ """
+ LOADING_TIMEOUT_MS = 10000
+ loading_gif = None
+
+ def __init__(self, timeout_message, *, parent=None, **kwargs):
+ self.timeout_message = timeout_message
+ super().__init__(parent=parent, **kwargs)
+ self._icon_size = QSize(32, 32)
+ if TyphosLoading.loading_gif is None:
+ loading_path = os.path.join(ui_dir, 'loading.gif')
+ TyphosLoading.loading_gif = QMovie(loading_path)
+ self._animation = TyphosLoading.loading_gif
+ self._animation.setScaledSize(self._icon_size)
+ self.setMovie(self._animation)
+ self._animation.start()
+ if self.LOADING_TIMEOUT_MS > 0:
+ QtCore.QTimer.singleShot(self.LOADING_TIMEOUT_MS,
+ self._handle_timeout)
+
+ self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu)
+
+
+[docs]
+ def contextMenuEvent(self, event):
+ menu = QtWidgets.QMenu(parent=self)
+
+ def copy_to_clipboard(*, text):
+ clipboard = QtWidgets.QApplication.instance().clipboard()
+ clipboard.setText(text)
+
+ menu.addSection('Copy to clipboard')
+ action = menu.addAction('&All')
+ action.triggered.connect(functools.partial(copy_to_clipboard,
+ text=self.toolTip()))
+ menu.addSeparator()
+
+ for line in self.toolTip().splitlines():
+ action = menu.addAction(line)
+ action.triggered.connect(
+ functools.partial(copy_to_clipboard, text=line)
+ )
+
+ menu.exec_(self.mapToGlobal(event.pos()))
+
+
+ def _handle_timeout(self):
+ self._animation.stop()
+ self.setMovie(None)
+ self.setText(self.timeout_message)
+
+ @property
+ def iconSize(self):
+ return self._icon_size
+
+ @iconSize.setter
+ def iconSize(self, size):
+ self._icon_size = size
+ self._animation.setScaledSize(self._icon_size)
+
+
+
+class TyphosObject:
+ def __init__(self, *args, **kwargs):
+ self.devices = list()
+ super().__init__(*args, **kwargs)
+
+ def add_device(self, device):
+ """
+ Add a new device to the widget
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ """
+ logger.debug("Adding device %s ...", device.name)
+ self.devices.append(device)
+
+ def paintEvent(self, event):
+ # This is necessary because by default QWidget ignores stylesheets
+ # https://wiki.qt.io/How_to_Change_the_Background_Color_of_QWidget
+ opt = QtWidgets.QStyleOption()
+ opt.initFrom(self)
+ painter = QPainter()
+ painter.begin(self)
+ self.style().drawPrimitive(QtWidgets.QStyle.PE_Widget, opt, painter,
+ self)
+ super().paintEvent(event)
+
+ @classmethod
+ def from_device(cls, device, parent=None, **kwargs):
+ """
+ Create a new instance of the widget for a Device
+
+ Shortcut for:
+
+ .. code::
+
+ tool = TyphosBase(parent=parent)
+ tool.add_device(device)
+
+ Parameters
+ ----------
+ device: ophyd.Device
+
+ parent: QWidget
+ """
+ instance = cls(parent=parent, **kwargs)
+ instance.add_device(device)
+ return instance
+
+
+
+[docs]
+class WeakPartialMethodSlot:
+ """
+ A PyQt-compatible slot for a partial method.
+
+ This utility handles deleting the connection when the method class instance
+ gets garbage collected. This avoids cycles in the garbage collector
+ that would prevent the instance from being garbage collected prior to the
+ program exiting.
+
+ Parameters
+ ----------
+ signal_owner : QtCore.QObject
+ The owner of the signal.
+ signal : QtCore.Signal
+ The signal instance itself.
+ method : instance method
+ The method slot to call when the signal fires.
+ *args :
+ Arguments to pass to the method.
+ **kwargs :
+ Keyword arguments to pass to the method.
+ """
+ def __init__(
+ self,
+ signal_owner: QtCore.QObject,
+ signal: QtCore.Signal,
+ method: MethodType,
+ *args,
+ **kwargs
+ ):
+ self.signal = signal
+ self.signal.connect(self._call, QtCore.Qt.QueuedConnection)
+ self.method = weakref.WeakMethod(method)
+ self._method_finalizer = weakref.finalize(
+ method.__self__, self._method_destroyed
+ )
+ self._signal_finalizer = weakref.finalize(
+ signal_owner, self._signal_destroyed
+ )
+ self.partial_args = args
+ self.partial_kwargs = kwargs
+
+ def _signal_destroyed(self):
+ """Callback: the owner of the signal was destroyed; clean up."""
+ if self.signal is None:
+ return
+
+ self.method = None
+ self.partial_args = []
+ self.partial_kwargs = {}
+ self.signal = None
+
+ def _method_destroyed(self):
+ """Callback: the owner of the method was destroyed; clean up."""
+ if self.signal is None:
+ return
+
+ self.method = None
+ self.partial_args = []
+ self.partial_kwargs = {}
+ try:
+ self.signal.disconnect(self._call)
+ except Exception:
+ ...
+ self.signal = None
+
+ def _call(self, *new_args):
+ """
+ PyQt callback slot which handles the internal WeakMethod.
+
+ This method currently throws away arguments passed in from the signal.
+ This is for backward-compatibility to how the previous
+ `partial()`-using implementation worked.
+
+ If reused beyond the TyphosSuite, this class may need revisiting in the
+ future.
+ """
+ method = self.method()
+ if method is None:
+ self._method_destroyed()
+ return
+
+ return method(*self.partial_args, **self.partial_kwargs)
+
+
+
+
+[docs]
+class TyphosBase(TyphosObject, QWidget):
+ """Base widget for all Typhos widgets that interface with devices"""
+
+ _weak_partials_: list[WeakPartialMethodSlot]
+
+ def __init__(self, *args, **kwargs):
+ self._weak_partials_ = []
+ super().__init__(*args, **kwargs)
+
+ def _connect_partial_weakly(
+ self,
+ signal_owner: QtCore.QObject,
+ signal: QtCore.Signal,
+ method: MethodType,
+ *args,
+ **kwargs
+ ):
+ """
+ Connect the provided signal to an instance method via
+ WeakPartialMethodSlot.
+
+ Parameters
+ ----------
+ signal_owner : QtCore.QObject
+ The owner of the signal.
+ signal : QtCore.Signal
+ The signal instance itself.
+ method : instance method
+ The method slot to call when the signal fires.
+ *args :
+ Arguments to pass to the method.
+ **kwargs :
+ Keyword arguments to pass to the method.
+ """
+ slot = WeakPartialMethodSlot(
+ signal_owner, signal, method, *args, **kwargs
+ )
+ self._weak_partials_.append(slot)
+
+
+
+
+[docs]
+def make_identifier(name):
+ """Make a Python string into a valid Python identifier"""
+ # That was easy
+ if name.isidentifier():
+ return name
+ # Lowercase
+ name = name.lower()
+ # Leading / following whitespace
+ name = name.strip()
+ # Intermediate whitespace should be underscores
+ name = re.sub('[\\s\\t\\n]+', '_', name)
+ # Remove invalid characters
+ name = re.sub('[^0-9a-zA-Z_]', '', name)
+ # Remove leading characters until we find a letter or an underscore
+ name = re.sub('^[^a-zA-Z_]+', '', name)
+ return name
+
+
+
+
+[docs]
+def flatten_tree(param):
+ """Flatten a tree of parameters"""
+ tree = [param]
+ for child in param.childs:
+ tree.extend(flatten_tree(child))
+ return tree
+
+
+
+
+[docs]
+def clear_layout(layout):
+ """Clear a QLayout"""
+ while layout.count():
+ child = layout.takeAt(0)
+ if child.widget():
+ child.widget().deleteLater()
+ elif child.layout():
+ clear_layout(child.layout())
+
+
+
+
+[docs]
+def reload_widget_stylesheet(widget, cascade=False):
+ """Reload the stylesheet of the provided widget"""
+ widget.style().unpolish(widget)
+ widget.style().polish(widget)
+ widget.update()
+ if cascade:
+ for child in widget.children():
+ if isinstance(child, QWidget):
+ reload_widget_stylesheet(child, cascade=True)
+
+
+
+
+[docs]
+def save_suite(suite, file_or_buffer):
+ """
+ Create a file capable of relaunching the TyphosSuite
+
+ Parameters
+ ----------
+ suite: TyphosSuite
+
+ file_or_buffer : str or file-like
+ Either a path to the file or a handle that supports ``write``
+ """
+ # Accept file-like objects or a handle
+ if isinstance(file_or_buffer, str):
+ handle = open(file_or_buffer, 'w+')
+ else:
+ handle = file_or_buffer
+ logger.debug("Saving TyphosSuite contents to %r", handle)
+ devices = [device.name for device in suite.devices]
+ handle.write(saved_template.format(devices=devices))
+
+
+
+
+[docs]
+def load_suite(path, cfg=None):
+ """"
+ Load a file saved via Typhos
+
+ Parameters
+ ----------
+ path: str
+ Path to file describing the ``TyphosSuite``. This needs to be of the
+ format created by the :meth:`.save_suite` function.
+
+ cfg: str, optional
+ Location of happi configuration file to use to load devices. If not
+ entered the ``$HAPPI_CFG`` environment variable will be used.
+ Returns
+ -------
+ suite: TyphosSuite
+ """
+ logger.info("Importing TyphosSuite from file %r ...", path)
+ module_name = pathlib.Path(path).name.replace('.py', '')
+ spec = importlib.util.spec_from_file_location(module_name,
+ path)
+ suite_module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(suite_module)
+ if hasattr(suite_module, 'create_suite'):
+ logger.debug("Executing create_suite method from %r", suite_module)
+ return suite_module.create_suite(cfg=cfg)
+ else:
+ raise AttributeError("Imported module has no 'create_suite' method!")
+
+
+
+saved_template = """\
+import sys
+import typhos.cli
+
+devices = {devices}
+
+def create_suite(cfg=None):
+ return typhos.cli.create_suite(devices, cfg=cfg)
+
+if __name__ == '__main__':
+ typhos.cli.typhos_cli(devices + sys.argv[1:])
+"""
+
+
+
+[docs]
+@contextlib.contextmanager
+def no_device_lazy_load():
+ '''
+ Context manager which disables the ophyd.device.Device
+ `lazy_wait_for_connection` behavior and later restore its value.
+ '''
+ old_val = Device.lazy_wait_for_connection
+ try:
+ Device.lazy_wait_for_connection = False
+ yield
+ finally:
+ Device.lazy_wait_for_connection = old_val
+
+
+
+
+[docs]
+def pyqt_class_from_enum(enum):
+ '''
+ Create an inheritable base class from a Python Enum, which can also be used
+ for Q_ENUMS.
+ '''
+ enum_dict = {item.name: item.value for item in list(enum)}
+ return type(enum.__name__, (object, ), enum_dict)
+
+
+
+def _get_template_filenames_for_class(class_, view_type, *, include_mro=True):
+ '''
+ Yields all possible template filenames that can be used for the class, in
+ order of priority, including those in the class MRO.
+
+ This does not include the file extension, to be appended by the caller.
+ '''
+ for cls in class_.mro():
+ module = cls.__module__
+ name = cls.__name__
+ yield f'{module}.{name}.{view_type}'
+ yield f'{name}.{view_type}'
+ yield f'{name}'
+
+ if not include_mro:
+ break
+
+
+
+[docs]
+def remove_duplicate_items(list_):
+ 'Return a de-duplicated list/tuple of items in `list_`, retaining order'
+ cls = type(list_)
+ return cls(sorted(set(list_), key=list_.index))
+
+
+
+
+[docs]
+def is_standard_template(template):
+ """
+ Is the template a core one provided with typhos?
+
+ Parameters
+ ----------
+ template : str or pathlib.Path
+ """
+ common_path = pathlib.Path(os.path.commonpath((template, ui_core_dir)))
+ return common_path == ui_core_dir
+
+
+
+
+[docs]
+def find_templates_for_class(cls, view_type, paths, *, extensions=None,
+ include_mro=True):
+ '''
+ Given a class `cls` and a view type (such as 'detailed'), search `paths`
+ for potential templates to show.
+
+ Parameters
+ ----------
+ cls : class
+ Search for templates with this class name
+ view_type : {'detailed', 'engineering', 'embedded'}
+ The view type
+ paths : iterable
+ Iterable of paths to be expanded, de-duplicated, and searched
+ extensions : str or list, optional
+ The template filename extension (default is ``'.ui'`` or ``'.py'``)
+ include_mro : bool, optional
+ Include superclasses - those in the MRO - of ``cls`` as well
+
+ Yields
+ ------
+ path : pathlib.Path
+ A matching path, ordered from most-to-least specific.
+ '''
+ if not inspect.isclass(cls):
+ cls = type(cls)
+
+ if not extensions:
+ extensions = ['.py', '.ui']
+ elif isinstance(extensions, str):
+ extensions = [extensions]
+
+ from .cache import _CachedPath
+ paths = remove_duplicate_items(
+ [_CachedPath.from_path(p) for p in paths]
+ )
+
+ for candidate_filename in _get_template_filenames_for_class(
+ cls, view_type, include_mro=include_mro):
+ for extension in extensions:
+ for path in paths:
+ for match in path.glob(candidate_filename + extension):
+ if match.is_file():
+ yield match
+
+
+
+
+[docs]
+def find_file_in_paths(filename, *, paths=None):
+ '''
+ Search for filename ``filename`` in the list of paths ``paths``
+
+ Parameters
+ ----------
+ filename : str or pathlib.Path
+ The filename
+ paths : list or iterable, optional
+ List of paths to search. Defaults to DISPLAY_PATHS.
+
+ Yields
+ ------
+ All filenames that match in the given paths
+ '''
+ if paths is None:
+ paths = DISPLAY_PATHS
+
+ if isinstance(filename, pathlib.Path):
+ if filename.is_absolute():
+ if filename.exists():
+ yield filename
+ return
+
+ filename = filename.name
+
+ from .cache import _CachedPath
+ paths = remove_duplicate_items(
+ [_CachedPath.from_path(p) for p in paths]
+ )
+
+ for path in paths:
+ for match in path.glob(filename):
+ if match.is_file():
+ yield match
+
+
+
+
+[docs]
+def get_device_from_fake_class(cls):
+ """
+ Return the non-fake class, given a fake class
+
+ That is::
+
+ fake_cls = ophyd.sim.make_fake_device(cls)
+ get_device_from_fake_class(fake_cls) # -> cls
+
+ Parameters
+ ----------
+ cls : type
+ The fake class
+ """
+ bases = cls.__bases__
+ if not bases or len(bases) != 1:
+ raise ValueError('Not a fake class based on inheritance')
+
+ actual_class, = bases
+
+ if actual_class not in ophyd.sim.fake_device_cache:
+ raise ValueError('Not a fake class (ophyd.sim does not know about it)')
+
+ return actual_class
+
+
+
+
+[docs]
+def is_fake_device_class(cls):
+ """
+ Is ``cls`` a fake device from :func:`ophyd.sim.make_fake_device`?
+ """
+ try:
+ get_device_from_fake_class(cls)
+ except ValueError:
+ return False
+ return True
+
+
+
+
+[docs]
+def code_from_device_repr(device):
+ """
+ Return code to create a device from its ``repr`` information.
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ """
+ try:
+ module = device.__module__
+ except AttributeError:
+ raise ValueError('Device class must be in a module') from None
+
+ class_name = device.__class__.__name__
+ if module == '__main__':
+ raise ValueError('Device class must be in a module')
+
+ cls = device.__class__
+ is_fake = is_fake_device_class(cls)
+
+ full_class_name = f'{module}.{class_name}'
+ kwargs = '\n '.join(f'{k}={v!r},' for k, v in device._repr_info())
+ logger.debug('%r fully qualified Device class: %r', device.name,
+ full_class_name)
+ if is_fake:
+ actual_class = get_device_from_fake_class(cls)
+ actual_name = f'{actual_class.__module__}.{actual_class.__name__}'
+ logger.debug('%r fully qualified Device class is fake, based on: %r',
+ device.name, actual_class)
+ return f'''\
+import ophyd.sim
+import pcdsutils
+
+{actual_class.__name__} = pcdsutils.utils.import_helper({actual_name!r})
+{class_name} = ophyd.sim.make_fake_device({actual_class.__name__})
+{device.name} = {class_name}(
+ {kwargs}
+)
+ophyd.sim.clear_fake_device({device.name})
+'''
+
+ return f'''\
+import pcdsutils
+
+{class_name} = pcdsutils.utils.import_helper({full_class_name!r})
+{device.name} = {class_name}(
+ {kwargs}
+)
+'''
+
+
+
+
+[docs]
+def code_from_device(device):
+ """
+ Generate code required to load ``device`` in another process
+ """
+ is_fake = is_fake_device_class(device.__class__)
+ if happi is None or not hasattr(device, 'md') or is_fake:
+ return code_from_device_repr(device)
+
+ happi_name = device.md.name
+ return f'''\
+import happi
+from happi.loader import from_container
+client = happi.Client.from_config()
+md = client.find_item(name="{happi_name}")
+{device.name} = from_container(md)
+'''
+
+
+
+
+[docs]
+@contextlib.contextmanager
+def subscription_context(*objects, callback, event_type=None, run=True):
+ '''
+ [Context manager] Subscribe to a specific event from all objects
+
+ Unsubscribes all signals before exiting
+
+ Parameters
+ ----------
+ *objects : ophyd.OphydObj
+ Ophyd objects (signals) to monitor
+ callback : callable
+ Callback to run, with same signature as that of
+ :meth:`ophyd.OphydObj.subscribe`.
+ event_type : str, optional
+ The event type to subscribe to
+ run : bool, optional
+ Run the previously cached subscription immediately
+ '''
+ obj_to_cid = {}
+ try:
+ for obj in objects:
+ try:
+ obj_to_cid[obj] = obj.subscribe(callback,
+ event_type=event_type, run=run)
+ except Exception:
+ logger.exception('Failed to subscribe to object %s', obj.name)
+ yield dict(obj_to_cid)
+ finally:
+ for obj, cid in obj_to_cid.items():
+ try:
+ obj.unsubscribe(cid)
+ except KeyError:
+ # It's possible that when the object is being torn down, or
+ # destroyed that this has already been done.
+ ...
+
+
+
+
+[docs]
+def get_all_signals_from_device(device, include_lazy=False, filter_by=None):
+ '''
+ Get all signals in a given device
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ ophyd Device to monitor
+ include_lazy : bool, optional
+ Include lazy signals as well
+ filter_by : callable, optional
+ Filter signals, with signature ``callable(ophyd.Device.ComponentWalk)``
+ '''
+ if not filter_by:
+ def filter_by(walk):
+ return True
+
+ def _get_signals():
+ return [
+ walk.item
+ for walk in device.walk_signals(include_lazy=include_lazy)
+ if filter_by(walk)
+ ]
+
+ if not include_lazy:
+ return _get_signals()
+
+ with no_device_lazy_load():
+ return _get_signals()
+
+
+
+
+[docs]
+@contextlib.contextmanager
+def subscription_context_device(device, callback, event_type=None, run=True, *,
+ include_lazy=False, filter_by=None):
+ '''
+ [Context manager] Subscribe to ``event_type`` from signals in ``device``
+
+ Unsubscribes all signals before exiting
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ ophyd Device to monitor
+ callback : callable
+ Callback to run, with same signature as that of
+ :meth:`ophyd.OphydObj.subscribe`
+ event_type : str, optional
+ The event type to subscribe to
+ run : bool, optional
+ Run the previously cached subscription immediately
+ include_lazy : bool, optional
+ Include lazy signals as well
+ filter_by : callable, optional
+ Filter signals, with signature ``callable(ophyd.Device.ComponentWalk)``
+ '''
+ signals = get_all_signals_from_device(device, include_lazy=include_lazy)
+ with subscription_context(*signals, callback=callback,
+ event_type=event_type, run=run) as obj_to_cid:
+ yield obj_to_cid
+
+
+
+class _ConnectionStatus:
+ def __init__(self, callback):
+ self.connected = set()
+ self.callback = callback
+ self.lock = threading.Lock()
+ # NOTE: this will be set externally
+ self.obj_to_cid = {}
+ self.objects = set()
+
+ def clear(self):
+ for obj in list(self.objects):
+ self.remove_object(obj)
+
+ def _run_callback_hack_on_object(self, obj):
+ '''
+ HACK: peek into ophyd objects to see if they're connected but have
+ never run metadata callbacks
+
+ This is part of an ongoing ophyd issue and may be removed in the
+ future.
+ '''
+ if obj not in self.objects:
+ return
+
+ if obj.connected and obj._args_cache.get('meta') is None:
+ md = dict(obj.metadata)
+ if 'connected' not in md:
+ md['connected'] = True
+ self._connection_callback(obj=obj, **md)
+
+ def add_object(self, obj):
+ 'Add an additional object to be monitored'
+ with self.lock:
+ if obj in self.objects:
+ return
+
+ self.objects.add(obj)
+ try:
+ self.obj_to_cid[obj] = obj.subscribe(
+ self._connection_callback, event_type='meta', run=True)
+ except Exception:
+ logger.exception('Failed to subscribe to object: %s', obj.name)
+ self.objects.remove(obj)
+ else:
+ self._run_callback_hack_on_object(obj)
+
+ def remove_object(self, obj):
+ 'Remove an object from being monitored - no more callbacks'
+ with self.lock:
+ if obj in self.connected:
+ self.connected.remove(obj)
+
+ self.objects.remove(obj)
+ cid = self.obj_to_cid.pop(obj)
+ try:
+ obj.unsubscribe(cid)
+ except KeyError:
+ # It's possible that when the object is being torn down, or
+ # destroyed that this has already been done.
+ ...
+
+ def _connection_callback(self, *, obj, connected, **kwargs):
+ with self.lock:
+ if obj not in self.objects:
+ # May have been removed
+ return
+
+ if connected and obj not in self.connected:
+ self.connected.add(obj)
+ elif not connected and obj in self.connected:
+ self.connected.remove(obj)
+ else:
+ return
+
+ logger.debug('Connection update: %r (obj=%s connected=%s kwargs=%r)',
+ self, obj.name, connected, kwargs)
+ self.callback(obj=obj, connected=connected, **kwargs)
+
+ def __repr__(self):
+ return (
+ f'<{self.__class__.__name__} connected={len(self.connected)} '
+ f'objects={len(self.objects)}>'
+ )
+
+
+
+[docs]
+@contextlib.contextmanager
+def connection_status_monitor(*signals, callback):
+ '''
+ [Context manager] Monitor connection status from a number of signals
+
+ Filters out any other metadata updates, only calling once
+ connected/disconnected
+
+ Parameters
+ ----------
+ *signals : ophyd.OphydObj
+ Signals to monitor
+ callback : callable
+ Callback to run, with same signature as that of
+ :meth:`ophyd.OphydObj.subscribe`. ``obj`` and ``connected`` are
+ guaranteed kwargs.
+ '''
+
+ status = _ConnectionStatus(callback)
+
+ with subscription_context(*signals, callback=status._connection_callback,
+ event_type='meta', run=True
+ ) as status.obj_to_cid:
+ for sig in signals:
+ status._run_callback_hack_on_object(sig)
+
+ yield status
+
+
+
+
+[docs]
+class DeviceConnectionMonitorThread(QtCore.QThread):
+ '''
+ Monitor connection status in a background thread
+
+ Parameters
+ ----------
+ device : ophyd.Device
+ The device to grab signals from
+ include_lazy : bool, optional
+ Include lazy signals as well
+
+ Attributes
+ ----------
+ connection_update : QtCore.Signal
+ Connection update signal with signature::
+
+ (signal, connected, metadata_dict)
+ '''
+
+ connection_update = QtCore.Signal(object, bool, dict)
+
+ def __init__(self, device, include_lazy=False, **kwargs):
+ super().__init__(**kwargs)
+ self.device = device
+ self.include_lazy = include_lazy
+ self._update_event = threading.Event()
+
+ atexit.register(self.stop)
+
+
+[docs]
+ def stop(self, *, wait_ms: int = 1000):
+ """
+ Stop the background thread and clean up.
+
+ Parameters
+ ----------
+ wait_ms : int, optional
+ Time to wait for the background thread to exit. Set to 0 to
+ disable.
+ """
+ if not self.isRunning():
+ return
+
+ self.requestInterruption()
+ if wait_ms > 0:
+ self.wait(msecs=wait_ms)
+
+
+ def callback(self, obj, connected, **kwargs):
+ self._update_event.set()
+ self.connection_update.emit(obj, connected, kwargs)
+
+
+[docs]
+ def run(self):
+ signals = get_all_signals_from_device(
+ self.device, include_lazy=self.include_lazy)
+
+ with connection_status_monitor(*signals, callback=self.callback):
+ while not self.isInterruptionRequested():
+ self._update_event.clear()
+ self._update_event.wait(timeout=0.25)
+
+
+
+
+
+[docs]
+class ObjectConnectionMonitorThread(QtCore.QThread):
+ '''
+ Monitor connection status in a background thread
+
+ Attributes
+ ----------
+ connection_update : QtCore.Signal
+ Connection update signal with signature::
+
+ (signal, connected, metadata_dict)
+ '''
+
+ connection_update = QtCore.Signal(object, bool, dict)
+
+ def __init__(self, objects=None, **kwargs):
+ super().__init__(**kwargs)
+ self._init_objects = list(objects or [])
+ self.status = None
+ self.lock = threading.Lock()
+ self._update_event = threading.Event()
+
+ atexit.register(self.stop)
+
+
+[docs]
+ def stop(self, *, wait_ms: int = 1000):
+ """
+ Stop the background thread and clean up.
+
+ Parameters
+ ----------
+ wait_ms : int, optional
+ Time to wait for the background thread to exit. Set to 0 to
+ disable.
+ """
+ if not self.isRunning():
+ return
+
+ self.requestInterruption()
+ if wait_ms > 0:
+ self.wait(msecs=wait_ms)
+
+
+ def clear(self):
+ if self.status:
+ self.status.clear()
+
+ def add_object(self, obj):
+ with self.lock:
+ # If the thread hasn't started yet, add it to the list
+ if self.status is None:
+ self._init_objects.append(obj)
+ return
+
+ self.status.add_object(obj)
+
+ def remove_object(self, obj):
+ with self.lock:
+ # If the thread hasn't started yet, remove it prior to monitoring
+ if self.status is None:
+ self._init_objects.remove(obj)
+ return
+
+ self.status.remove_object(obj)
+
+ def callback(self, obj, connected, **kwargs):
+ self._update_event.set()
+ self.connection_update.emit(obj, connected, kwargs)
+
+
+[docs]
+ def run(self):
+ self.lock.acquire()
+ try:
+ with connection_status_monitor(
+ *self._init_objects,
+ callback=self.callback) as self.status:
+ self._init_objects.clear()
+ self.lock.release()
+ while not self.isInterruptionRequested():
+ self._update_event.clear()
+ self._update_event.wait(timeout=0.25)
+ finally:
+ if self.lock.locked():
+ self.lock.release()
+
+
+
+
+
+[docs]
+class ThreadPoolWorker(QtCore.QRunnable):
+ '''
+ Worker thread helper
+
+ Parameters
+ ----------
+ func : callable
+ The function to call during :meth:`.run`
+ *args
+ Arguments for the function call
+ **kwargs
+ Keyword rarguments for the function call
+ '''
+
+ def __init__(self, func, *args, **kwargs):
+ super().__init__()
+ self.func = func
+ self.args = args
+ self.kwargs = kwargs
+
+
+[docs]
+ @QtCore.Slot()
+ def run(self):
+ try:
+ self.func(*self.args, **self.kwargs)
+ except Exception:
+ logger.exception('Failed to run %s(*%s, **%r) in thread pool',
+ self.func, self.args, self.kwargs)
+
+
+
+
+def _get_top_level_components(device_cls):
+ """Get all top-level components from a device class."""
+ return list(device_cls._sig_attrs.items())
+
+
+
+[docs]
+def find_root_widget(widget: QtWidgets.QWidget) -> QtWidgets.QWidget:
+ """
+ Finds the root ancestor of a widget.
+
+ Parameters
+ ----------
+ widget : QWidget
+ The widget from which to start the search
+ """
+ parent = widget
+ while parent.parent() is not None:
+ parent = parent.parent()
+ return parent
+
+
+
+
+[docs]
+def find_parent_with_class(widget, cls=QWidget):
+ """
+ Finds the first parent of a widget that is an instance of ``klass``
+
+ Parameters
+ ----------
+ widget : QWidget
+ The widget from which to start the search
+ cls : type, optional
+ The class which the parent must be an instance of
+
+ """
+ parent = widget
+ while parent is not None:
+ if isinstance(parent, cls):
+ return parent
+ parent = parent.parent()
+ return None
+
+
+
+
+[docs]
+def dump_grid_layout(layout, rows=None, cols=None, *, cell_width=60):
+ """
+ Dump the layout of a :class:`QtWidgets.QGridLayout` to ``file``.
+
+ Parameters
+ ----------
+ layout : QtWidgets.QGridLayout
+ The layout
+ rows : int
+ Number of rows to iterate over
+ cols : int
+ Number of columns to iterate over
+
+ Returns
+ -------
+ table : str
+ The text for the summary table
+ """
+ rows = rows or layout.rowCount()
+ cols = cols or layout.columnCount()
+
+ separator = '-' * ((cell_width + 4) * cols)
+ cell = ' {:<%ds}' % cell_width
+
+ def get_text(item):
+ if not item:
+ return ''
+
+ entry = item.widget() or item.layout()
+ visible = entry is None or entry.isVisible()
+ if isinstance(entry, QtWidgets.QLabel):
+ entry = f'<QLabel {entry.text()!r}>'
+
+ if not visible:
+ entry = f'(invis) {entry}'
+ return entry
+
+ with io.StringIO() as file:
+ print(separator, file=file)
+ for row in range(rows):
+ print('|', end='', file=file)
+ for col in range(cols):
+ item = get_text(layout.itemAtPosition(row, col))
+ print(cell.format(str(item)), end=' |', file=file)
+
+ print(file=file)
+
+ print(separator, file=file)
+ return file.getvalue()
+
+
+
+
+[docs]
+@contextlib.contextmanager
+def nullcontext():
+ """Stand-in for py3.7's contextlib.nullcontext"""
+ yield
+
+
+
+
+[docs]
+def get_component(obj):
+ """
+ Get the component that made the given object.
+
+ Parameters
+ ----------
+ obj : ophyd.OphydItem
+ The ophyd item for which to get the component.
+
+ Returns
+ -------
+ component : ophyd.Component
+ The component, if available.
+ """
+ if obj.parent is None:
+ return None
+
+ return getattr(type(obj.parent), obj.attr_name, None)
+
+
+
+
+[docs]
+def get_variety_metadata(cpt):
+ """
+ Get "variety" metadata from a component or signal.
+
+ Parameters
+ ----------
+ cpt : ophyd.Component or ophyd.OphydItem
+ The component / ophyd item to get the metadata for.
+
+ Returns
+ -------
+ metadata : dict
+ The metadata, if set. Otherwise an empty dictionary. This metadata is
+ guaranteed to be valid according to the known schemas.
+ """
+ if not isinstance(cpt, ophyd.Component):
+ cpt = get_component(cpt)
+
+ return getattr(cpt, '_variety_metadata', {})
+
+
+
+
+[docs]
+def widget_to_image(widget, fill_color=QtCore.Qt.transparent):
+ """
+ Paint the given widget in a new QtGui.QImage.
+
+ Returns
+ -------
+ QtGui.QImage
+ The display, as an image.
+ """
+ image = QtGui.QImage(widget.width(), widget.height(),
+ QtGui.QImage.Format_ARGB32_Premultiplied)
+
+ image.fill(fill_color)
+ pixmap = QtGui.QPixmap(image)
+
+ painter = QtGui.QPainter(pixmap)
+ widget.render(image)
+ painter.end()
+ return image
+
+
+
+_connect_slots_unpatched = None
+
+
+
+[docs]
+def patch_connect_slots():
+ """
+ Patches QtCore.QMetaObject.connectSlotsByName to catch SystemErrors.
+ """
+ global _connect_slots_unpatched
+
+ if _connect_slots_unpatched is not None:
+ return
+
+ # TODO there could be a version check here if we can isolate it
+
+ _connect_slots_unpatched = QtCore.QMetaObject.connectSlotsByName
+
+ def connect_slots_patch(top_level_widget):
+ try:
+ return _connect_slots_unpatched(top_level_widget)
+ except SystemError as ex:
+ logger.debug(
+ "Eating system error. This may possibly be solved by either "
+ "downgrading Python or upgrading pyqt5 to >= 5.13.1. "
+ "For further discussion, see "
+ "https://github.com/pcdshub/typhos/issues/354",
+ exc_info=ex
+ )
+
+ QtCore.QMetaObject.connectSlotsByName = connect_slots_patch
+
+
+
+
+[docs]
+def link_signal_to_widget(signal, widget):
+ """
+ Registers the signal with PyDM, and sets the widget channel.
+
+ Parameters
+ ----------
+ signal : ophyd.OphydObj
+ The signal to use.
+
+ widget : QtWidgets.QWidget
+ The widget with which to connect the signal.
+ """
+ if signal is not None:
+ plugins.register_signal(signal)
+ if widget is not None:
+ read = not isinstance(widget, PyDMWritableWidget)
+ widget.channel = channel_from_signal(signal, read=read)
+
+
+
+
+[docs]
+def linked_attribute(property_attr, widget_attr, hide_unavailable=False):
+ """
+ Decorator which connects a device signal with a widget.
+
+ Retrieves the signal from the device, registers it with PyDM, and sets the
+ widget channel.
+
+ Parameters
+ ----------
+ property_attr : str
+ This is one level of indirection, allowing for the component attribute
+ to be configurable by way of designable properties.
+ In short, this looks like:
+ ``getattr(self.device, getattr(self, property_attr))``
+ The component attribute name may include multiple levels (e.g.,
+ ``'cpt1.cpt2.low_limit'``).
+
+ widget_attr : str
+ The attribute name of the widget, referenced from ``self``.
+ The component attribute name may include multiple levels (e.g.,
+ ``'ui.low_limit'``).
+
+ hide_unavailable : bool
+ Whether or not to hide widgets for which the device signal is not
+ available
+ """
+ get_widget_attr = operator.attrgetter(widget_attr)
+
+ def wrapper(func):
+ @functools.wraps(func)
+ def wrapped(self):
+ widget = get_widget_attr(self)
+ device_attr = getattr(self, property_attr)
+ get_device_attr = operator.attrgetter(device_attr)
+
+ try:
+ signal = get_device_attr(self.device)
+ except AttributeError:
+ signal = None
+ else:
+ # Fall short of an `isinstance(signal, OphydObj) check here:
+ try:
+ link_signal_to_widget(signal, widget)
+ except Exception:
+ logger.exception(
+ 'device.%s => self.%s (signal: %s widget: %s)',
+ device_attr, widget_attr, signal, widget)
+ signal = None
+ else:
+ logger.debug('device.%s => self.%s (signal=%s widget=%s)',
+ device_attr, widget_attr, signal, widget)
+
+ if signal is None and hide_unavailable:
+ widget.setVisible(False)
+
+ return func(self, signal, widget)
+
+ return wrapped
+ return wrapper
+
+
+
+
+[docs]
+def raise_window(widget):
+ """
+ Bring a widget's window into focus and on top of the window stack.
+
+ If the window is minimized, unminimize it.
+
+ Different window managers respond differently to the various
+ methods called here, the chosen sequence was intended for
+ good behavior on as many systems as possible.
+ """
+ window = widget.window()
+ window.hide()
+ window.show()
+ if window.isMinimized():
+ window.showNormal()
+ window.raise_()
+ window.activateWindow()
+ window.setFocus()
+
+
+
+
+[docs]
+class FrameOnEditFilter(QtCore.QObject):
+ """
+ A QLineEdit event filter for editing vs not editing style handling.
+ This will make the QLineEdit look like a QLabel when the user is
+ not editing it.
+ """
+
+[docs]
+ def eventFilter(self, object: QtWidgets.QLineEdit, event: QtCore.QEvent) -> bool:
+ # Even if we install only on line edits, this can be passed a generic
+ # QWidget when we remove and clean up the line edit widget.
+ if not isinstance(object, QtWidgets.QLineEdit):
+ return False
+ if event.type() == QtCore.QEvent.FocusIn:
+ self.set_edit_style(object)
+ return False
+ if event.type() == QtCore.QEvent.FocusOut:
+ self.set_no_edit_style(object)
+ return False
+ return False
+
+
+
+[docs]
+ @staticmethod
+ def set_edit_style(object: QtWidgets.QLineEdit):
+ """
+ Set a QLineEdit to the look and feel we want for editing.
+ Parameters
+ ----------
+ object : QLineEdit
+ Any line edit widget.
+ """
+ object.setFrame(True)
+ color = object.palette().color(QtGui.QPalette.ColorRole.Base)
+ object.setStyleSheet(
+ f"QLineEdit {{ background: rgba({color.red()},"
+ f"{color.green()}, {color.blue()}, {color.alpha()})}}"
+ )
+ object.setReadOnly(False)
+
+
+
+[docs]
+ @staticmethod
+ def set_no_edit_style(object: QtWidgets.QLineEdit):
+ """
+ Set a QLineEdit to the look and feel we want for not editing.
+ Parameters
+ ----------
+ object : QLineEdit
+ Any line edit widget.
+ """
+ if object.text():
+ object.setFrame(False)
+ object.setStyleSheet(
+ "QLineEdit { background: transparent }"
+ )
+ object.setReadOnly(True)
+
+
+
+
+
+[docs]
+def take_widget_screenshot(widget: QtWidgets.QWidget) -> Optional[QtGui.QImage]:
+ """Take a screenshot of the given widget, returning a QImage."""
+
+ app = QtWidgets.QApplication.instance()
+ if app is None:
+ # No apps, no screenshots!
+ return None
+
+ try:
+ primary_screen: QtGui.QScreen = app.primaryScreen()
+ logger.debug("Primary screen: %s", primary_screen)
+
+ screen = (
+ widget.screen()
+ if hasattr(widget, "screen")
+ else primary_screen
+ )
+
+ logger.info(
+ "Screenshot: %s (%s, primary screen: %s widget screen: %s)",
+ widget.windowTitle(),
+ widget,
+ primary_screen.name(),
+ screen.name(),
+ )
+ return screen.grabWindow(widget.winId())
+ except RuntimeError as ex:
+ # The widget may have been deleted already; do not fail in this
+ # scenario.
+ logger.debug("Widget %s screenshot failed due to: %s", type(widget), ex)
+ return None
+
+
+
+
+[docs]
+def take_top_level_widget_screenshots(
+ *, visible_only: bool = True,
+) -> Generator[
+ tuple[QtWidgets.QWidget, QtGui.QImage], None, None
+]:
+ """
+ Yield screenshots of all top-level widgets.
+
+ Parameters
+ ----------
+ visible_only : bool, optional
+ Only take screenshots of visible widgets.
+
+ Yields
+ ------
+ widget : QtWidgets.QWidget
+ The widget relating to the screenshot.
+
+ screenshot : QtGui.QImage
+ The screenshot image.
+ """
+ app = QtWidgets.QApplication.instance()
+ if app is None:
+ # No apps, no screenshots!
+ return
+
+ for screen_idx, screen in enumerate(app.screens(), 1):
+ logger.debug(
+ "Screen %d: %s %s %s",
+ screen_idx,
+ screen,
+ screen.name(),
+ screen.geometry(),
+ )
+
+ def by_title(widget):
+ return widget.windowTitle() or str(id(widget))
+
+ def should_take_screenshot(widget: QtWidgets.QWidget) -> bool:
+ try:
+ parent = widget.parent()
+ visible = widget.isVisible()
+ except RuntimeError:
+ # Widget could have been gc'd in the meantime
+ return False
+
+ if isinstance(widget, QtWidgets.QMenu):
+ logger.debug(
+ "Skipping QMenu for screenshots. %s parent=%s",
+ widget,
+ parent,
+ )
+ return False
+ if visible_only and not visible:
+ return False
+ return True
+
+ for widget in sorted(app.topLevelWidgets(), key=by_title):
+ if should_take_screenshot(widget):
+ image = take_widget_screenshot(widget)
+ if image is not None:
+ yield widget, image
+
+
+
+
+[docs]
+def load_ui_file(
+ uifile: str,
+ macros: Optional[Dict[str, str]] = None,
+) -> pydm.Display:
+ """
+ Load a .ui file, perform macro substitution, then return the resulting QWidget.
+
+ Parameters
+ ----------
+ uifile : str
+ The path to a .ui file to load.
+ macros : dict, optional
+ A dictionary of macro variables to supply to the file to be opened.
+
+ Returns
+ -------
+ pydm.Display
+ """
+
+ display = pydm.Display(macros=macros)
+ try:
+ display.load_ui_from_file(uifile, macros)
+ except Exception as ex:
+ ex.pydm_display = display
+ raise
+
+ return display
+
+
+"""
+Typhos widgets and related utilities.
+"""
+
+import collections
+import datetime
+import inspect
+import logging
+
+import numpy as np
+import pydm
+import pydm.widgets
+import pydm.widgets.base
+import pydm.widgets.byte
+import pydm.widgets.enum_button
+import qtawesome as qta
+from ophyd.signal import EpicsSignalBase
+from pydm.widgets.display_format import DisplayFormat
+from pyqtgraph.parametertree import ParameterItem
+from qtpy import QtGui, QtWidgets
+from qtpy.QtCore import Property, QObject, QSize, Qt, Signal, Slot
+from qtpy.QtWidgets import (QAction, QDialog, QDockWidget, QPushButton,
+ QToolBar, QVBoxLayout, QWidget)
+
+from . import dynamic_font, plugins, utils, variety
+from .textedit import TyphosTextEdit # noqa: F401
+from .tweakable import TyphosTweakable # noqa: F401
+from .variety import use_for_variety_read, use_for_variety_write
+
+logger = logging.getLogger(__name__)
+
+EXPONENTIAL_UNITS = ['mtorr', 'torr', 'kpa', 'pa']
+
+
+
+[docs]
+class SignalWidgetInfo(
+ collections.namedtuple(
+ 'SignalWidgetInfo',
+ 'read_cls read_kwargs write_cls write_kwargs'
+ )):
+ """
+ Provides information on how to create signal widgets: class and kwargs.
+
+ Parameters
+ ----------
+ read_cls : type
+ The readback widget class.
+
+ read_kwargs : dict
+ The readback widget initialization keyword arguments.
+
+ write_cls : type
+ The setpoint widget class.
+
+ write_kwargs : dict
+ The setpoint widget initialization keyword arguments.
+ """
+
+
+[docs]
+ @classmethod
+ def from_signal(cls, obj, desc=None):
+ """
+ Create a `SignalWidgetInfo` given an object and its description.
+
+ Parameters
+ ----------
+ obj : :class:`ophyd.OphydObj`
+ The object
+
+ desc : dict, optional
+ The object description, if available.
+ """
+ if desc is None:
+ desc = obj.describe()
+
+ read_cls, read_kwargs = widget_type_from_description(
+ obj, desc, read_only=True)
+
+ is_read_only = utils.is_signal_ro(obj) or (
+ read_cls is not None and issubclass(read_cls, SignalDialogButton))
+
+ if is_read_only:
+ write_cls = None
+ write_kwargs = {}
+ else:
+ write_cls, write_kwargs = widget_type_from_description(obj, desc)
+
+ return cls(read_cls, read_kwargs, write_cls, write_kwargs)
+
+
+
+
+class TogglePanel(QWidget):
+ """
+ Generic Panel Widget
+
+ Displays a widget below QPushButton that hides and shows the contents. It
+ is up to subclasses to re-point the attribute :attr:`.contents` to the
+ widget whose visibility you would like to toggle.
+
+ By default, it is assumed that the Panel is initialized with the
+ :attr:`.contents` widget as visible, however the contents will be hidden
+ and the button synced to the proper position if :meth:`.show_contents` is
+ called after instance creation
+
+ Parameters
+ ----------
+ title : str
+ Title of Panel. This will be the text on the QPushButton
+
+ parent : QWidget
+
+ Attributes
+ ----------
+ contents : QWidget
+ Widget whose visibility is controlled via the QPushButton
+ """
+ def __init__(self, title, parent=None):
+ super().__init__(parent=parent)
+ # Create Widget Infrastructure
+ self.title = title
+ self.setLayout(QVBoxLayout())
+ self.layout().setContentsMargins(2, 2, 2, 2)
+ self.layout().setSpacing(5)
+ # Create button control
+ # Assuming widget is visible, set the button as checked
+ self.contents = None
+ self.hide_button = QPushButton(self.title)
+ self.hide_button.setCheckable(True)
+ self.hide_button.setChecked(True)
+ self.layout().addWidget(self.hide_button)
+ self.hide_button.clicked.connect(self.show_contents)
+
+ @Slot(bool)
+ def show_contents(self, show):
+ """
+ Show the contents of the Widget
+
+ Hides the :attr:`.contents` QWidget and sets the :attr:`.hide_button`
+ to the proper status to indicate whether the widget is hidden or not
+
+ Parameters
+ ----------
+ show : bool
+ """
+ # Configure our button in case this slot was called elsewhere
+ self.hide_button.setChecked(show)
+ # Show or hide the widget if the contents exist
+ if self.contents:
+ if show:
+ self.show()
+ self.contents.show()
+ else:
+ self.contents.hide()
+
+
+
+[docs]
+@use_for_variety_write('enum')
+@use_for_variety_write('text-enum')
+class TyphosComboBox(pydm.widgets.PyDMEnumComboBox):
+ """
+ Notes
+ -----
+ """
+ def __init__(self, *args, variety_metadata=None, ophyd_signal=None,
+ **kwargs):
+ super().__init__(*args, **kwargs)
+ self.ophyd_signal = ophyd_signal
+ self._ophyd_enum_strings = None
+ self._md_sub = ophyd_signal.subscribe(
+ self._metadata_update, event_type="meta"
+ )
+
+ def __dtor__(self):
+ """PyQt5 destructor hook."""
+ if self._md_sub is not None:
+ self.ophyd_signal.unsubscribe(self._md_sub)
+ self._md_sub = None
+
+ def _metadata_update(self, enum_strs=None, **kwargs):
+ if enum_strs:
+ self._ophyd_enum_strings = tuple(enum_strs)
+ self.enum_strings_changed(enum_strs)
+
+
+[docs]
+ def enum_strings_changed(self, new_enum_strings):
+ current_idx = self.currentIndex()
+ super().enum_strings_changed(
+ tuple(self._ophyd_enum_strings or new_enum_strings)
+ )
+ self.value_changed(current_idx)
+
+
+
+
+
+
+
+class NoScrollComboBox(QtWidgets.QComboBox):
+ """
+ A combobox disconnected from direct EPICS/ophyd with scrolling ignored.
+ """
+ def wheelEvent(self, event: QtGui.QWheelEvent):
+ event.ignore()
+
+
+
+[docs]
+@use_for_variety_write('scalar')
+@use_for_variety_write('text')
+class TyphosLineEdit(pydm.widgets.PyDMLineEdit):
+ """
+ Reimplementation of PyDMLineEdit to set some custom defaults
+
+ Notes
+ -----
+ """
+ def __init__(self, *args, display_format=None, **kwargs):
+ self._channel = None
+ self._setpoint_history_count = 5
+ self._setpoint_history = collections.deque(
+ [], self._setpoint_history_count)
+
+ super().__init__(*args, **kwargs)
+ self.showUnits = True
+ if display_format is not None:
+ self.displayFormat = display_format
+
+ def __dtor__(self):
+ menu = self.unitMenu
+ if menu is not None:
+ menu.deleteLater()
+ self.unitMenu = None
+
+ @property
+ def setpoint_history(self):
+ """
+ History of setpoints, as a dictionary of {setpoint: timestamp}
+ """
+ return dict(self._setpoint_history)
+
+ @Property(int, designable=True)
+ def setpointHistoryCount(self):
+ """
+ Number of items to show in the context menu "setpoint history"
+ """
+ return self._setpoint_history_count
+
+ @setpointHistoryCount.setter
+ def setpointHistoryCount(self, value):
+ self._setpoint_history_count = max((0, int(value)))
+ self._setpoint_history = collections.deque(
+ self._setpoint_history, self._setpoint_history_count)
+
+ def _remove_history_item_by_value(self, remove_value):
+ """
+ Remove an item from the history buffer by value
+ """
+ new_history = [(value, ts) for value, ts in self._setpoint_history
+ if value != remove_value]
+ self._setpoint_history = collections.deque(
+ new_history, self._setpoint_history_count)
+
+ def _add_history_item(self, value, *, timestamp=None):
+ """
+ Add an item to the history buffer
+ """
+ if value in dict(self._setpoint_history):
+ # Push this value to the end of the list as most-recently used
+ self._remove_history_item_by_value(value)
+
+ self._setpoint_history.append(
+ (value, timestamp or datetime.datetime.now())
+ )
+
+
+[docs]
+ def send_value(self):
+ """
+ Update channel value while recording setpoint history
+ """
+ value = self.text().strip()
+ retval = super().send_value()
+ self._add_history_item(value)
+ return retval
+
+
+ def _create_history_menu(self):
+ if not self._setpoint_history:
+ return None
+
+ history_menu = QtWidgets.QMenu("&History")
+ font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont)
+ history_menu.setFont(font)
+
+ max_len = max(len(value)
+ for value, timestamp in self._setpoint_history)
+
+ # Pad values such that timestamp lines up:
+ # (Value) @ (Timestamp)
+ action_format = '{value:<%d} @ {timestamp}' % (max_len + 1)
+
+ for value, timestamp in reversed(self._setpoint_history):
+ timestamp = timestamp.strftime('%m/%d %H:%M')
+ action = history_menu.addAction(
+ action_format.format(value=value, timestamp=timestamp))
+
+ def history_selected(*, value=value):
+ self.setText(str(value))
+
+ action.triggered.connect(history_selected)
+
+ return history_menu
+
+
+
+
+
+[docs]
+ def unit_changed(self, new_unit):
+ """
+ Callback invoked when the Channel has new unit value.
+ This callback also triggers an update_format_string call so the
+ new unit value is considered if ```showUnits``` is set.
+
+ Parameters
+ ----------
+ new_unit : str
+ The new unit
+ """
+ if self._unit == new_unit:
+ return
+
+ super().unit_changed(new_unit)
+ default = (self.displayFormat == DisplayFormat.Default)
+ if new_unit.lower() in EXPONENTIAL_UNITS and default:
+ self.displayFormat = DisplayFormat.Exponential
+
+
+
+
+
+[docs]
+@use_for_variety_read('array-nd')
+@use_for_variety_read('command-enum')
+@use_for_variety_read('command-setpoint-tracks-readback')
+@use_for_variety_read('enum')
+@use_for_variety_read('scalar')
+@use_for_variety_read('scalar-range')
+@use_for_variety_read('scalar-tweakable')
+@use_for_variety_read('text')
+@use_for_variety_read('text-enum')
+@use_for_variety_read('text-multiline')
+@use_for_variety_write('array-nd')
+class TyphosLabel(pydm.widgets.PyDMLabel):
+ """
+ Reimplementation of PyDMLabel to set some custom defaults
+
+ Notes
+ -----
+ """
+ def __init__(
+ self, *args, display_format=None, ophyd_signal=None, **kwargs
+ ):
+ super().__init__(*args, **kwargs)
+ self.setAlignment(Qt.AlignCenter)
+ self.setSizePolicy(QtWidgets.QSizePolicy.Preferred,
+ QtWidgets.QSizePolicy.Maximum)
+ self.showUnits = True
+ if display_format is not None:
+ self.displayFormat = display_format
+
+ self.ophyd_signal = ophyd_signal
+ self._ophyd_enum_strings = None
+ self._md_sub = ophyd_signal.subscribe(
+ self._metadata_update, event_type="meta"
+ )
+
+ def __dtor__(self):
+ """PyQt5 destructor hook."""
+ if self._md_sub is not None:
+ self.ophyd_signal.unsubscribe(self._md_sub)
+ self._md_sub = None
+
+ def _metadata_update(self, enum_strs=None, **kwargs):
+ if enum_strs:
+ self._ophyd_enum_strings = tuple(enum_strs)
+ self.enum_strings_changed(enum_strs)
+
+
+[docs]
+ def enum_strings_changed(self, new_enum_strings):
+ super().enum_strings_changed(
+ tuple(self._ophyd_enum_strings or new_enum_strings)
+ )
+
+
+
+[docs]
+ def unit_changed(self, new_unit):
+ """
+ Callback invoked when the Channel has new unit value.
+ This callback also triggers an update_format_string call so the
+ new unit value is considered if ```showUnits``` is set.
+
+ Parameters
+ ----------
+ new_unit : str
+ The new unit
+ """
+ if self._unit == new_unit:
+ return
+
+ super().unit_changed(new_unit)
+ default = (self.displayFormat == DisplayFormat.Default)
+ if new_unit.lower() in EXPONENTIAL_UNITS and default:
+ self.displayFormat = DisplayFormat.Exponential
+
+
+ @Property(bool, "dynamicFontSize")
+ def dynamic_font_size(self) -> bool:
+ """Dynamically adjust the font size"""
+ return dynamic_font.is_patched(self)
+
+ @dynamic_font_size.setter
+ def dynamic_font_size(self, value: bool):
+ if value:
+ dynamic_font.patch_widget(self)
+ else:
+ dynamic_font.unpatch_widget(self)
+
+
+
+
+[docs]
+class TyphosSidebarItem(ParameterItem):
+ """
+ Class to display a Device or Tool in the sidebar
+
+ Notes
+ -----
+ """
+ def __init__(self, param, depth):
+ super().__init__(param, depth)
+ # Configure a QToolbar
+ self.toolbar = QToolBar()
+ self.toolbar.setToolButtonStyle(Qt.ToolButtonIconOnly)
+ self.toolbar.setIconSize(QSize(15, 15))
+ # Setup the action to open the widget
+ self.open_action = QAction(
+ qta.icon('fa5s.square', color='green'), 'Open', self.toolbar)
+ self.open_action.triggered.connect(self.open_requested)
+ # Setup the action to embed the widget
+ self.embed_action = QAction(
+ qta.icon('fa5s.th-large', color='yellow'), 'Embed', self.toolbar)
+ self.embed_action.triggered.connect(self.embed_requested)
+ # Setup the action to hide the widget
+ self.hide_action = QAction(
+ qta.icon('fa5s.times-circle', color='red'), 'Close', self.toolbar)
+ self.hide_action.triggered.connect(self.hide_requested)
+ self.hide_action.setEnabled(False)
+ # Add actions to toolbars
+ self.toolbar.addAction(self.open_action)
+ self.toolbar.addAction(self.hide_action)
+ if self.param.embeddable:
+ self.toolbar.insertAction(self.hide_action, self.embed_action)
+
+
+[docs]
+ def open_requested(self, triggered):
+ """Request to open display for sidebar item"""
+ self.param.sigOpen.emit(self)
+ self._mark_shown()
+
+
+
+[docs]
+ def embed_requested(self, triggered):
+ """Request to open embedded display for sidebar item"""
+ self.param.sigEmbed.emit(self)
+ self._mark_shown()
+
+
+
+[docs]
+ def hide_requested(self, triggered):
+ """Request to hide display for sidebar item"""
+ self.param.sigHide.emit(self)
+ self._mark_hidden()
+
+
+ def _mark_shown(self):
+ self.open_action.setEnabled(False)
+ self.embed_action.setEnabled(False)
+ self.hide_action.setEnabled(True)
+
+ def _mark_hidden(self):
+ self.open_action.setEnabled(True)
+ self.embed_action.setEnabled(True)
+ self.hide_action.setEnabled(False)
+
+
+[docs]
+ def treeWidgetChanged(self):
+ """Update the widget when add to a QTreeWidget"""
+ super().treeWidgetChanged()
+ tree = self.treeWidget()
+ if tree is None:
+ return
+ tree.setItemWidget(self, 1, self.toolbar)
+
+
+
+
+
+[docs]
+class SubDisplay(QDockWidget):
+ """QDockWidget modified to emit a signal when closed"""
+ closing = Signal()
+
+
+
+
+
+
+class HappiChannel(pydm.widgets.channel.PyDMChannel, QObject):
+ """
+ PyDMChannel to transport Device Information
+
+ Parameters
+ ----------
+ tx_slot: callable
+ Slot on widget to accept a dictionary of both the device and metadata
+ information
+ """
+
+ def __init__(self, *, tx_slot, **kwargs):
+ super().__init__(**kwargs)
+ QObject.__init__(self)
+ self._tx_slot = tx_slot
+ self._last_md = None
+
+ @Slot(dict)
+ def tx_slot(self, value):
+ """Transmission Slot"""
+ # Do not fire twice for the same device
+ if not self._last_md or self._last_md != value['md']:
+ self._last_md = value['md']
+ self._tx_slot(value)
+ else:
+ logger.debug("HappiChannel %r received same device. "
+ "Ignoring for now ...", self)
+
+
+
+[docs]
+class TyphosDesignerMixin(pydm.widgets.base.PyDMWidget):
+ """
+ A mixin class used to display Typhos widgets in the Qt designer.
+ """
+
+ _qt_designer_ = {
+ "group": "Typhos Widgets",
+ "is_container": False,
+ }
+
+ # Unused properties that we don't want visible in designer
+ alarmSensitiveBorder = Property(bool, designable=False)
+ alarmSensitiveContent = Property(bool, designable=False)
+ precisionFromPV = Property(bool, designable=False)
+ precision = Property(int, designable=False)
+ showUnits = Property(bool, designable=False)
+
+ @Property(str)
+ def channel(self):
+ """The channel address to use for this widget"""
+ if self._channel:
+ return str(self._channel)
+ return None
+
+ @channel.setter
+ def channel(self, value):
+ if self._channel != value:
+ # Remove old connection
+ if self._channels:
+ self._channels.clear()
+ for channel in self._channels:
+ if hasattr(channel, 'disconnect'):
+ channel.disconnect()
+ # Load new channel
+ self._channel = str(value)
+ channel = HappiChannel(address=self._channel,
+ tx_slot=self._tx)
+ self._channels = [channel]
+ # Connect the channel to the HappiPlugin
+ if hasattr(channel, 'connect'):
+ channel.connect()
+
+ @Slot(object)
+ def _tx(self, value):
+ """Receive information from happi channel"""
+ self.add_device(value['obj'])
+
+
+
+
+[docs]
+class SignalDialogButton(QPushButton):
+ """QPushButton to launch a QDialog with a PyDMWidget"""
+ text = NotImplemented
+ icon = NotImplemented
+ parent_widget_class = QtWidgets.QWidget
+
+ def __init__(self, init_channel, text=None, icon=None, parent=None):
+ self.text = text or self.text
+ self.icon = icon or self.icon
+ super().__init__(qta.icon(self.icon), self.text, parent=parent)
+ self.clicked.connect(self.show_dialog)
+ self.dialog = None
+ self.channel = init_channel
+ self.setIconSize(QSize(15, 15))
+
+
+[docs]
+ def widget(self):
+ """Return a widget created with channel"""
+ raise NotImplementedError
+
+
+
+[docs]
+ def show_dialog(self) -> QDialog:
+ """Show the channel in a QDialog"""
+ # Dialog Creation
+ if not self.dialog:
+ logger.debug("Creating QDialog for %r", self.channel)
+ # Set up the QDialog
+ parent = utils.find_parent_with_class(
+ self, self.parent_widget_class)
+ self.dialog = QDialog(parent)
+ self.dialog.setWindowTitle(self.channel)
+ self.dialog.setLayout(QVBoxLayout())
+ self.dialog.layout().setContentsMargins(2, 2, 2, 2)
+ # Add the widget
+ widget = self.widget()
+ self.dialog.layout().addWidget(widget)
+ # Handle a lost dialog
+ else:
+ logger.debug("Redisplaying QDialog for %r", self.channel)
+ self.dialog.close()
+ # Show the dialog
+ logger.debug("Showing QDialog for %r", self.channel)
+ self.dialog.show()
+ return self.dialog
+
+
+
+
+
+[docs]
+@use_for_variety_read('array-image')
+class ImageDialogButton(SignalDialogButton):
+ """
+ QPushButton to show a 2-d array.
+
+ Notes
+ -----
+ """
+ text = "Show Image"
+ icon = "fa5s.camera"
+ parent_widget_class = QtWidgets.QMainWindow
+
+
+[docs]
+ def widget(self):
+ """Create PyDMImageView"""
+ return pydm.widgets.PyDMImageView(
+ parent=self, image_channel=self.channel)
+
+
+
+
+
+[docs]
+@use_for_variety_read('array-timeseries')
+@use_for_variety_read('array-histogram') # TODO: histogram settings?
+class WaveformDialogButton(SignalDialogButton):
+ """
+ QPushButton to show a 1-d array.
+
+ Notes
+ -----
+ """
+ text = 'Show Waveform'
+ icon = "fa5s.chart-line"
+ parent_widget_class = QtWidgets.QMainWindow
+
+
+[docs]
+ def widget(self):
+ """Create PyDMWaveformPlot"""
+ return pydm.widgets.PyDMWaveformPlot(
+ init_y_channels=[self.channel], parent=self)
+
+
+
+
+# @variety.uses_key_handlers
+
+[docs]
+@use_for_variety_write('command')
+@use_for_variety_write('command-proc')
+@use_for_variety_write('command-setpoint-tracks-readback') # TODO
+class TyphosCommandButton(pydm.widgets.PyDMPushButton):
+ """
+ A pushbutton widget which executes a command by sending a specific value.
+
+ See Also
+ --------
+ :class:`TyphosCommandEnumButton`
+
+ Notes
+ -----
+ """
+
+ default_label = 'Command'
+
+ def __init__(self, *args, variety_metadata=None, ophyd_signal=None,
+ **kwargs):
+ super().__init__(*args, **kwargs)
+ self.ophyd_signal = ophyd_signal
+ self.variety_metadata = variety_metadata
+ self._forced_enum_strings = None
+
+ variety_metadata = variety.create_variety_property()
+
+
+[docs]
+ def enum_strings_changed(self, new_enum_strings):
+ return super().enum_strings_changed(
+ self._forced_enum_strings or new_enum_strings)
+
+
+ def _update_variety_metadata(self, *, value, enum_strings=None,
+ enum_dict=None, tags=None, **kwargs):
+ self.pressValue = value
+ enum_strings = variety.get_enum_strings(enum_strings, enum_dict)
+ if enum_strings is not None:
+ self._forced_enum_strings = tuple(enum_strings)
+ self.enum_strings_changed(None) # force an update
+
+ tags = set(tags or {})
+
+ if 'protected' in tags:
+ self.passwordProtected = True
+ self.password = 'typhos' # ... yeah (TODO)
+
+ if 'confirm' in tags:
+ self.showConfirmDialog = True
+
+ variety._warn_unhandled_kwargs(self, kwargs)
+
+ if not self.text():
+ self.setText(self.default_label)
+
+
+
+
+[docs]
+@variety.uses_key_handlers
+@use_for_variety_write('command-enum')
+class TyphosCommandEnumButton(pydm.widgets.enum_button.PyDMEnumButton):
+ """
+ A group of buttons which represent several command options.
+
+ These options can come from directly from the control layer or can be
+ overridden with variety metadata.
+
+ See Also
+ --------
+ :class:`TyphosCommandButton`
+
+ Notes
+ -----
+ """
+
+ def __init__(self, *args, variety_metadata=None, ophyd_signal=None,
+ **kwargs):
+ super().__init__(*args, **kwargs)
+ self.ophyd_signal = ophyd_signal
+ self.variety_metadata = variety_metadata
+ self._forced_enum_strings = None
+
+ variety_metadata = variety.create_variety_property()
+
+
+[docs]
+ def enum_strings_changed(self, new_enum_strings):
+ return super().enum_strings_changed(
+ self._forced_enum_strings or new_enum_strings)
+
+
+ def _update_variety_metadata(self, *, value, enum_strings=None,
+ enum_dict=None, tags=None, **kwargs):
+ enum_strings = variety.get_enum_strings(enum_strings, enum_dict)
+ if enum_strings is not None:
+ self._forced_enum_strings = tuple(enum_strings)
+ self.enum_strings_changed(None) # force an update
+
+ variety._warn_unhandled_kwargs(self, kwargs)
+
+
+
+
+[docs]
+@use_for_variety_read('bitmask')
+@variety.uses_key_handlers
+class TyphosByteIndicator(pydm.widgets.PyDMByteIndicator):
+ """
+ Displays an integer value as individual, read-only bit indicators.
+
+ Notes
+ -----
+ """
+
+ def __init__(self, *args, variety_metadata=None, ophyd_signal=None,
+ **kwargs):
+ super().__init__(*args, **kwargs)
+ self.ophyd_signal = ophyd_signal
+ self.variety_metadata = variety_metadata
+
+ variety_metadata = variety.create_variety_property()
+
+ def _update_variety_metadata(self, *, bits, orientation, first_bit, style,
+ meaning=None, tags=None, **kwargs):
+ self.numBits = bits
+ self.orientation = {
+ 'horizontal': Qt.Horizontal,
+ 'vertical': Qt.Vertical,
+ }[orientation]
+ self.bigEndian = (first_bit == 'most-significant')
+ # TODO: labels do not display properly
+ # if meaning:
+ # self.labels = meaning[:bits]
+ # self.showLabels = True
+ variety._warn_unhandled_kwargs(self, kwargs)
+
+ @variety.key_handler('style')
+ def _variety_key_handler_style(self, *, shape, on_color, off_color,
+ **kwargs):
+ """Variety hook for the sub-dictionary "style"."""
+ on_color = QtGui.QColor(on_color)
+ if on_color is not None:
+ self.onColor = on_color
+
+ off_color = QtGui.QColor(off_color)
+ if off_color is not None:
+ self.offColor = off_color
+
+ self.circles = (shape == 'circle')
+
+ variety._warn_unhandled_kwargs(self, kwargs)
+
+
+
+@use_for_variety_read('command')
+@use_for_variety_read('command-proc')
+class TyphosCommandIndicator(pydm.widgets.PyDMByteIndicator):
+ """Displays command status as a read-only bit indicator."""
+
+ def __init__(self, *args, ophyd_signal=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.ophyd_signal = ophyd_signal
+ self.numBits = 1
+ self.showLabels = False
+ self.circles = True
+
+
+
+[docs]
+class ClickableBitIndicator(pydm.widgets.byte.PyDMBitIndicator):
+ """A bit indicator that emits `clicked` when clicked."""
+ clicked = Signal()
+
+
+[docs]
+ def mousePressEvent(self, event: QtGui.QMouseEvent):
+ super().mousePressEvent(event)
+ if event.button() == Qt.LeftButton:
+ self.clicked.emit()
+
+
+
+
+
+[docs]
+@use_for_variety_write('bitmask')
+class TyphosByteSetpoint(TyphosByteIndicator,
+ pydm.widgets.base.PyDMWritableWidget):
+ """
+ Displays an integer value as individual, toggleable bit indicators.
+
+ Notes
+ -----
+ """
+
+ def __init__(self, *args, variety_metadata=None, ophyd_signal=None,
+ **kwargs):
+ # NOTE: need to have these in the signature explicitly
+ super().__init__(*args, variety_metadata=variety_metadata,
+ ophyd_signal=ophyd_signal, **kwargs)
+
+ self._requests_pending = {}
+
+ def _get_setpoint_from_requests(self):
+ setpoint = self.value
+ for bit, request in self._requests_pending.items():
+ mask = 1 << bit
+ if request:
+ setpoint |= mask
+ else:
+ setpoint &= ~mask
+ return setpoint
+
+ def _bit_clicked(self, bit):
+ if bit in self._requests_pending:
+ old_value = self._requests_pending[bit]
+ else:
+ old_value = bool(self.value & (1 << bit))
+
+ self._requests_pending[bit] = not old_value
+ self.send_value_signal[int].emit(self._get_setpoint_from_requests())
+
+
+[docs]
+ def value_changed(self, value):
+ """Receive and update the TyphosTextEdit for a new channel value."""
+ for bit, request in list(self._requests_pending.items()):
+ mask = 1 << bit
+ is_set = bool(value & mask)
+ if is_set == request:
+ self._requests_pending.pop(bit, None)
+
+ super().value_changed(value)
+
+
+ @Property(int, designable=True)
+ def numBits(self):
+ """
+ Number of bits to interpret.
+
+ Re-implemented from PyDM to support changing of bit indicators.
+ """
+ return self._num_bits
+
+ @numBits.setter
+ def numBits(self, num_bits):
+ if num_bits < 1:
+ return
+
+ self._num_bits = num_bits
+ for indicator in self._indicators:
+ indicator.deleteLater()
+
+ self._indicators = [
+ ClickableBitIndicator(parent=self, circle=self.circles)
+ for bit in range(self._num_bits)
+ ]
+
+ for bit, indicator in enumerate(self._indicators):
+ def indicator_clicked(*, bit=bit):
+ self._bit_clicked(bit)
+
+ indicator.clicked.connect(indicator_clicked)
+
+ new_labels = [f"Bit {bit}"
+ for bit in range(len(self.labels), self._num_bits)]
+ self.labels = self.labels + new_labels
+
+
+
+
+[docs]
+@variety.uses_key_handlers
+@use_for_variety_write('scalar-range')
+class TyphosScalarRange(pydm.widgets.PyDMSlider):
+ """
+ A slider widget which displays a scalar value with an explicit range.
+
+ Notes
+ -----
+ """
+
+ def __init__(self, *args, variety_metadata=None, ophyd_signal=None,
+ **kwargs):
+ super().__init__(*args, **kwargs)
+ self.ophyd_signal = ophyd_signal
+ self._delta_value = None
+ self._delta_signal = None
+ self._delta_signal_sub = None
+ self.variety_metadata = variety_metadata
+
+ variety_metadata = variety.create_variety_property()
+
+ def __dtor__(self):
+ """PyQt5 destructor hook"""
+ # Ensure our delta signal subscription is cleared:
+ if self._delta_signal is not None:
+ if self._delta_signal_sub is not None:
+ self._delta_signal.unsubscribe(self._delta_signal_sub)
+ self._delta_signal_sub = None
+ self.delta_signal = None
+
+ @variety.key_handler('range')
+ def _variety_key_handler_range(self, value, source, **kwargs):
+ """Variety hook for the sub-dictionary "range"."""
+ if source == 'value':
+ if value is not None:
+ low, high = value
+ self.userMinimum = low
+ self.userMaximum = high
+ self.userDefinedLimits = True
+ # elif source == 'use_limits':
+ else:
+ variety._warn_unhandled(self, 'range.source', source)
+
+ variety._warn_unhandled_kwargs(self, kwargs)
+
+ @variety.key_handler('delta')
+ def _variety_key_handler_delta(self, source, value=None, signal=None,
+ **kwargs):
+ """Variety hook for the sub-dictionary "delta"."""
+ if source == 'value':
+ if value is not None:
+ self.delta_value = value
+ elif source == 'signal':
+ if signal is not None:
+ self.delta_signal = variety.get_referenced_signal(self, signal)
+ else:
+ variety._warn_unhandled(self, 'delta.source', source)
+
+ # range_ = kwargs.pop('range') # unhandled
+ variety._warn_unhandled_kwargs(self, kwargs)
+
+ @variety.key_handler('display_format')
+ def _variety_key_handler_display_format(self, value):
+ """Variety hook for the sub-dictionary "delta"."""
+ self.displayFormat = variety.get_display_format(value)
+
+ @property
+ def delta_signal(self):
+ """Delta signal, used as the source for "delta_value"."""
+ return self._delta_signal
+
+ @delta_signal.setter
+ def delta_signal(self, signal):
+ if self._delta_signal is not None:
+ self._delta_signal.unsubscribe(self._delta_signal_sub)
+ self._delta_signal_sub = None
+
+ if signal is None:
+ return
+
+ def update_delta(value, **kwargs):
+ self.delta_value = value
+
+ self._delta_signal_sub = signal.subscribe(update_delta)
+ self._delta_signal = signal
+
+ @Property(float, designable=True)
+ def delta_value(self):
+ """
+ Delta value, an alternative to "num_points" provided by PyDMSlider.
+
+ num_points is calculated using the current min/max and the delta value,
+ if set.
+ """
+ return self._delta_value
+
+ @delta_value.setter
+ def delta_value(self, value):
+ if value is None:
+ self._delta_value = None
+ return
+ if value <= 0.0:
+ return
+
+ self._delta_value = value
+ if self.minimum is not None and self.maximum is not None:
+ self._mute_internal_slider_changes = True
+ try:
+ self.num_steps = (self.maximum - self.minimum) / value
+ except Exception:
+ logger.exception('Failed to set number of steps with '
+ 'min=%s, max=%s, delta=%s', self.minimum,
+ self.maximum, value)
+ finally:
+ self._mute_internal_slider_changes = False
+
+
+[docs]
+ def connection_changed(self, connected):
+ ret = super().connection_changed(connected)
+ if connected:
+ self.delta_value = self._delta_value
+ return ret
+
+
+
+
+
+[docs]
+@variety.uses_key_handlers
+@use_for_variety_write('array-tabular')
+class TyphosArrayTable(pydm.widgets.PyDMWaveformTable):
+ """
+ A table widget which reshapes and displays a given waveform value.
+
+ Notes
+ -----
+ """
+
+ def __init__(self, *args, variety_metadata=None, ophyd_signal=None,
+ **kwargs):
+ super().__init__(*args, **kwargs)
+ self.ophyd_signal = ophyd_signal
+ self.variety_metadata = variety_metadata
+
+ variety_metadata = variety.create_variety_property()
+
+
+[docs]
+ def value_changed(self, value):
+ try:
+ len(value)
+ except TypeError:
+ logger.debug('Non-waveform value? %r', value)
+ return
+
+ # shape = self.variety_metadata.get('shape')
+ # if shape is not None:
+ # expected_length = np.multiply.reduce(shape)
+ # if len(value) == expected_length:
+ # value = np.array(value).reshape(shape)
+
+ return super().value_changed(value)
+
+
+ def _calculate_size(self, padding=5):
+ width = self.verticalHeader().width() + padding
+ for col in range(self.columnCount()):
+ width += self.columnWidth(col)
+
+ height = self.horizontalHeader().height() + padding
+ for row in range(self.rowCount()):
+ height += self.rowHeight(row)
+
+ return QSize(width, height)
+
+ def _update_variety_metadata(self, *, shape=None, tags=None, **kwargs):
+ if shape:
+ # TODO
+ columns = max(shape[0], 1)
+ rows = max(np.product(shape[1:]), 1)
+ self.setRowCount(rows)
+ self.setColumnCount(columns)
+ self.rowHeaderLabels = [f'{idx}' for idx in range(rows)]
+ self.columnHeaderLabels = [f'{idx}' for idx in range(columns)]
+
+ if rows <= 5 and columns <= 5:
+ full_size = self._calculate_size()
+ self.setFixedSize(full_size)
+
+ variety._warn_unhandled_kwargs(self, kwargs)
+
+
+
+def _get_scalar_widget_class(desc, variety_md, read_only):
+ """
+ From a given description, return the widget to use.
+
+ Parameters
+ ----------
+ desc : dict
+ The object description.
+
+ variety_md : dict
+ The variety metadata. Currently unused.
+
+ read_only : bool
+ Set if used for the readback widget.
+ """
+ # Check for enum_strs, if so create a QCombobox
+ if read_only:
+ return TyphosLabel
+
+ if 'enum_strs' in desc:
+ # Create a QCombobox if the widget has enum_strs
+ return TyphosComboBox
+
+ # Otherwise a LineEdit will suffice
+ return TyphosLineEdit
+
+
+def _get_ndimensional_widget_class(dimensions, desc, variety_md, read_only):
+ """
+ From a given description and dimensionality, return the widget to use.
+
+ Parameters
+ ----------
+ dimensions : int
+ The number of dimensions (e.g., 0D or scalar, 1D array, ND array)
+
+ desc : dict
+ The object description.
+
+ variety_md : dict
+ The variety metadata. Currently unused.
+
+ read_only : bool
+ Set if used for the readback widget.
+ """
+ if dimensions == 0:
+ return _get_scalar_widget_class(desc, variety_md, read_only)
+
+ return {
+ 1: WaveformDialogButton,
+ 2: ImageDialogButton
+ }.get(dimensions, TyphosLabel)
+
+
+DIRECT_CONTROL_LAYERS = {"pyepics", "caproto"}
+
+
+
+[docs]
+def widget_type_from_description(signal, desc, read_only=False):
+ """
+ Determine which widget class should be used for the given signal
+
+ Parameters
+ ----------
+ signal : ophyd.Signal
+ Signal object to determine widget class
+
+ desc : dict
+ Previously recorded description from the signal
+
+ read_only: bool, optional
+ Should the chosen widget class be read-only?
+
+ Returns
+ -------
+ widget_class : class
+ The class to use for the widget
+ kwargs : dict
+ Keyword arguments for the class
+ """
+ use_pv_directly = (
+ # We can use PyDM's data source directly with EpicsSignalBase:
+ isinstance(signal, EpicsSignalBase) and
+ # So long as its underlying control layer is a supported ophyd-provided
+ # one, at least.
+ getattr(signal.cl, "name", "") in DIRECT_CONTROL_LAYERS
+ )
+ if use_pv_directly:
+ # Still re-route EpicsSignal through the ca:// plugin
+ pv = (signal._read_pv
+ if read_only else signal._write_pv)
+ init_channel = utils.channel_name(pv.pvname)
+ else:
+ # Register signal with plugin
+ plugins.register_signal(signal)
+ init_channel = utils.channel_name(signal.name, protocol='sig')
+
+ variety_metadata = utils.get_variety_metadata(signal)
+ kwargs = {
+ 'init_channel': init_channel,
+ }
+
+ if variety_metadata:
+ widget_cls = variety._get_widget_class_from_variety(
+ desc, variety_metadata, read_only)
+ else:
+ try:
+ dimensions = len(desc.get('shape', []))
+ except TypeError:
+ dimensions = 0
+
+ widget_cls = _get_ndimensional_widget_class(
+ dimensions, desc, variety_metadata, read_only)
+
+ if widget_cls is None:
+ return None, None
+
+ if desc.get('dtype') == 'string' and widget_cls in (TyphosLabel,
+ TyphosLineEdit):
+ kwargs['display_format'] = DisplayFormat.String
+
+ class_signature = inspect.signature(widget_cls)
+ if 'variety_metadata' in class_signature.parameters:
+ kwargs['variety_metadata'] = variety_metadata
+
+ if 'ophyd_signal' in class_signature.parameters:
+ kwargs['ophyd_signal'] = signal
+
+ return widget_cls, kwargs
+
+
+
+
+[docs]
+def determine_widget_type(signal, read_only=False):
+ """
+ Determine which widget class should be used for the given signal.
+
+ Parameters
+ ----------
+ signal : ophyd.Signal
+ Signal object to determine widget class
+
+ read_only: bool, optional
+ Should the chosen widget class be read-only?
+
+ Returns
+ -------
+ widget_class : class
+ The class to use for the widget
+ kwargs : dict
+ Keyword arguments for the class
+ """
+ try:
+ desc = signal.describe()[signal.name]
+ except Exception:
+ logger.error("Unable to connect to %r during widget creation",
+ signal.name)
+ desc = {}
+
+ return widget_type_from_description(signal, desc, read_only=read_only)
+
+
+
+
+[docs]
+def create_signal_widget(signal, read_only=False, tooltip=None):
+ """
+ Factory for creating a PyDMWidget from a signal
+
+ Parameters
+ ----------
+ signal : ophyd.Signal
+ Signal object to create widget
+
+ read_only: bool, optional
+ Whether this widget should be able to write back to the signal you
+ provided
+
+ tooltip : str, optional
+ Tooltip to use for the widget
+
+ Returns
+ -------
+ widget : PyDMWidget
+ PyDMLabel, PyDMLineEdit, or PyDMEnumComboBox based on whether we should
+ be able to write back to the widget and if the signal has ``enum_strs``
+ """
+ widget_cls, kwargs = determine_widget_type(signal, read_only=read_only)
+ if widget_cls is None:
+ return
+
+ logger.debug("Creating %s for %s", widget_cls, signal.name)
+
+ widget = widget_cls(**kwargs)
+ widget.setObjectName(f'{signal.name}_{widget_cls.__name__}')
+ if tooltip is not None:
+ widget.setToolTip(tooltip)
+
+ return widget
+
+
Note
" + + "" + msg + "
"; + var parent = document.querySelector('div.body') + || document.querySelector('div.document') + || document.body; + parent.insertBefore(warning, parent.firstChild); + } + + +} + +function addVersionsMenu() { + // We assume that we can load versions.json from + // https://' + + '' + + _("Hide Search Matches") + + "
" + ) + ); + }, + + /** + * helper function to hide the search marks again + */ + hideSearchWords: () => { + document + .querySelectorAll("#searchbox .highlight-link") + .forEach((el) => el.remove()); + document + .querySelectorAll("span.highlighted") + .forEach((el) => el.classList.remove("highlighted")); + localStorage.removeItem("sphinx_highlight_terms") + }, + + initEscapeListener: () => { + // only install a listener if it is really needed + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; + // bail with special keys + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; + if (DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS && (event.key === "Escape")) { + SphinxHighlight.hideSearchWords(); + event.preventDefault(); + } + }); + }, +}; + +_ready(() => { + /* Do not call highlightSearchWords() when we are on the search page. + * It will highlight words from the *previous* search query. + */ + if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); + SphinxHighlight.initEscapeListener(); +}); diff --git a/v4.0.0/basic_usage.html b/v4.0.0/basic_usage.html new file mode 100644 index 000000000..6d8f5fa1f --- /dev/null +++ b/v4.0.0/basic_usage.html @@ -0,0 +1,431 @@ + + + + + + +Typhos has three major building blocks that combine into the final display seen +by the operator:
+TyphosSuite : The overall view for a Typhos window. It allows the +operator to view all of the loaded components and tools.
TyphosDeviceDisplay : This is the widget created for a standard
+ophyd.Device
. Signals can be organized based on their Kind
and
+description.
typhos.tools : These are widgets that interface with external
+applications. While you may have other GUIs for these systems,
+typhos.tools
are built especially to handle the handshaking between all
+the information stored on your device and the tool you are interfacing with.
+This saves your operator clicks and ensures consistency in use.
All three of the widgets listed above share a similar API for creation.
+Instantiating the object by itself handles loading the container widgets and
+placing them in the correct place, but these do not accept ophyd.Device
+arguments. The reason for this is to ensure that we can use all of the
+typhos
screens as templates, and regardless or not of whether you have an
+ophyd.Device
you can always populate the screens by hand. If you do in fact
+have an ophyd.Device
every class has an add_device
method and
+alternatively and be constructed using the from_device
classmethod.
Base widget for all Typhos widgets that interface with devices
+Typhos interprets the internal structure of the ophyd.Device
to create the
+PyDM user interface, so the most intuitive way to configure the created
+display is to include components on the device itself. This also has the advantage
+of keeping your Python API and display in sync, making the transition from
+using screens to using an IPython shell seamless.
For the following applications we’ll use the motor
simulation contained
+within ophyd
itself. We also need to create a QApplication
before we
+create any widgets:
In [1]: from qtpy.QtWidgets import QApplication
+
+In [2]: app = QApplication([])
+
While happi
is not a requirement for using typhos
, it is recommended.
+For more information, visit the GitHub
+repository. The main purpose of the package is to store information on our
+Ophyd objects so that we can load them in a variety of contexts. If you do not
+use happi
you will need to create your objects and displays in the same
+process.
From the command-line, using typhos and happi together is easy. For example,
+to load an auto-generated typhos screen for your device named "my_device"
+would only require the following:
$ typhos my_device
+
typhos automatically configures the happi client, finds your device, and +creates an appropriate screen for it.
+If you are looking to integrate typhos at the Python source code level,
+consider the following example which uses typhos
with happi
:
import happi
+from typhos.plugins import register_client
+
+# Initialize a new JSON based client
+client = happi.Client(path='db.json', initialize=True)
+# Register this with typhos
+register_client(client)
+# Add a device to our new database
+device = happi.Device(device_class='ophyd.sim.SynAxis',
+ prefix='Tst:Mtr', args=[], kwargs='{{name}}',
+ name='my_motor', beamline='TST')
+client.add_device(device)
+
In practice, it is not necessary to call register_client()
if you have
+configured the $HAPPI_CFG
environment variable such that
+happi.Client.from_config
yields the desired client.
We can now check that we can load the complete SynAxis
object.
motor = client.load_device(name='my_motor')
+
When making a custom screen, you can access signals associated with your device +in several ways, in order of suggested use:
+By using the typhos built-in “signal” plugin to connect to the signal with
+the dotted ophyd name, just as you would use in an IPython session.
+In the designer “channel” property, specify: sig://device_name.attr
+with as many .attrs
required to reach the signal from the top-level
+device as needed.
+For example, for a motor named “my_motor”, you could use:
+sig://my_motor.user_readback
An alternate signal name is available, that which is seen by the data
+acquisition system (e.g., the databroker by way of bluesky). Generally,
+characters seen as invalid for a MongoDB are replaced with an underscore
+(_
). To check a signal’s name, see the .name
property of that
+signal.
+For example, for a motor named “my_motor”, you could use:
+sig://my_motor_user_readback
By PV name directly. Assuming your signal is available through the
+underlying control system (EPICS, for example), you could look and see which
+PVs your signal talks to and use those directly. That is,
+my_motor.user_readback.pvname
would tell you which EPICS PV the user
+readback uses. From there, you could set the widget’s channel to use EPICS
+Channel Access with ca://pv_name_here
.
The first thing we’ll talk about is showing a group of signals associated with
+our motor
object in a basic form called a
+TyphosSignalPanel
. Simply inspecting the device reveals a few
+signals for us to display
In [3]: motor.component_names
+Out[3]: ('readback', 'setpoint', 'velocity', 'acceleration', 'unused')
+
It is crucial that we understand the importance of these signals to the
+operator. In order to glean this information from the object the kind
+attributes are inspected. For more information see the ophyd documentation. A quick inspection of
+the various attributes allows us to see how our signals are organized.
# Most important signal(s)
+In [4]: motor.hints
+Out[4]: {'fields': ['motor']}
+
+# Important signals, all hints will be found here as well
+In [5]: motor.read()
+Out[5]:
+OrderedDict([('motor', {'value': 0, 'timestamp': 1724371220.4161754}),
+ ('motor_setpoint',
+ {'value': 0, 'timestamp': 1724371220.4161742})])
+
+# Configuration information
+In [6]: motor.read_configuration()
+Out[6]:
+OrderedDict([('motor_velocity', {'value': 1, 'timestamp': 1724371220.4166207}),
+ ('motor_acceleration',
+ {'value': 1, 'timestamp': 1724371220.4166954})])
+
The TyphosSignalPanel
will render these, allowing us to select a
+subset of the signals to display based on their kind. Below both the
+QtDesigner
using happi
and the corresponding Python
code is shown
+as well:
In [7]: from typhos import TyphosSignalPanel
+
+In [8]: panel = TyphosSignalPanel.from_device(motor)
+
Now, at first glance it may not be obvious, but there is a lot of information
+here! We know which of these signals an operator will want to control and which
+ones are purely meant to be read back. We also have these signals grouped by
+their importance to operation, each with a terse human-readable description of
+what the Signal
represents.
Taking this concept further, instead of filling a single panel
+TyphosDeviceDisplay
allows a template to be created with a multitude
+of widgets and panels. Typhos
will find widgets that accept devices, but do
+not have any devices already. Typhos comes with some default templates, and you
+can cycle between them by changing the display_type
Once again, both the Python
code and the QtDesigner
use cases are
+shown:
In [9]: from typhos import TyphosDeviceDisplay
+
+In [10]: display = TyphosDeviceDisplay.from_device(motor)
+
A complete application can be made by loading the TyphosSuite
. Below
+is the complete code from start to finish required to create the suite. Look at
+the TyphosSuited.default_tools
to control which typhos.tools
are
+loaded.
from ophyd.sim import motor
+from qtpy.QtWidgets import QApplication
+from typhos.suite import TyphosSuite
+from typhos.utils import apply_standard_stylesheets
+
+# Create our application
+app = QApplication([])
+apply_standard_stylesheets() # Optional
+suite = TyphosSuite.from_device(motor)
+
+# Launch
+suite.show()
+app.exec_()
+
Typhos ships with two stylesheets to improve the look and feel of the widgets.
+When invoking typhos
from the CLI as normal, you can pass
+the --dark
flag to use the dark stylesheet instead of the light mode,
+and a --stylesheet-add
argument to use your own stylesheet in addition to Typhos’s.
+If you want to completely ignore Typhos’s normal stylesheet loading and use your own,
+you can pass the --stylesheet-override
argument. You can pass these arguments
+multiple times to include multiple stylesheets.
Typhos also uses the same stylesheet environment variables as PyDM to load additional +stylesheets. The PyDM environment variables respected here are:
+PYDM_STYLESHEET
, a path-like variable that should contain file paths to qss
+stylesheets if set.
PYDM_STYLESHEET_INCLUDE_DEFAULT
, which should be set to 1 to include the
+default PyDM stylesheet or unset to not include it.
The priority order for stylesheets in the case of conflicts is:
+The explicit styleSheet
property on the display template
The style elements from --stylesheet-add
User stylesheets from PYDM_STYLESHEET_INCLUDE_DEFAULT
Typhos’s stylesheet (either the dark or the light variant)
The built-in PyDM stylesheet
Outside of the CLI, the stylesheets can be applied using typhos.apply_standard_stylesheets()
.
+This function also handles setting the “Fusion” QStyle
which helps
+make the interface have an operating system independent look and feel.
Typhos has a built-in documentation helper, which allows for the in-line +linking and display of a user-provided website.
+To inform Typhos how to load documentation specific to your facility, please +customize the following environment variables.
+TYPHOS_HELP_URL
(str): The help URL format string. The help URL will
+be formatted with the ophyd device pertinent to the display, such that you
+may access its name, PV prefix, happi metadata (if available), and so on.
+For example, if a Confluence server exists at
+https://my-confluence-site.example.com/Controls/
with document names
+that match your devices, TYPHOS_HELP_URL
should be set to
+https://my-confluence-site.example.com/Controls/{device.name}
.
+If, perhaps, only top-level devices are guaranteed to have documentation,
+consider using: device.root.name
instead in the format string.
TYPHOS_HELP_HEADERS
(json): headers to pass to HELP_URL. This should be
+in a JSON format, such as {"my_key":"my_value"}
.
TYPHOS_HELP_HEADERS_HOSTS
(str): comma-delimited hosts that headers may
+be sent to, aside from the host configured in TYPHOS_HELP_URL
.
TYPHOS_HELP_TOKEN
(str): An optional token for the bearer authentication
+scheme - e.g., personal access tokens with Confluence. This is a shortcut
+to add a header "Authorization"
with the value
+"Bearer ${TYPHOS_HELP_TOKEN}"
.
Typhos has an optional built-in widget to generate Jira user stories/bug +reports.
+A prerequisite to this support is, of course, a working Jira installation +and a pre-configured issue collector.
+TYPHOS_JIRA_URL
(str): The Jira issue collector URL. This will resemble
+https://jira.example.com/rest/collectors/1.0/template/custom/...
.
TYPHOS_JIRA_HEADERS
(json): headers to pass to the Jira request, if
+needed. This should be in a JSON format, such as {"my_key":"my_value"}
.
TYPHOS_JIRA_TOKEN
(str): An optional token for the bearer authentication
+scheme - e.g., personal access tokens with Confluence. This is a shortcut
+to add a header "Authorization"
with the value
+"Bearer ${TYPHOS_JIRA_TOKEN}"
.
TYPHOS_JIRA_EMAIL_SUFFIX
(str): the default e-mail suffix to put on
+usernames, such as "@example.com"
.
There are example screens in the typhos.examples
submodule. After
+installing typhos
, you can launch them as follows:
python -m typhos.examples.panel
python -m typhos.examples.positioner
This module defines the typhos
command line utility
usage: sphinx-build [-h] [--layout LAYOUT] [--cols COLS]
+ [--display-type DISPLAY_TYPE] [--scrollable SCROLLABLE]
+ [--size SIZE] [--hide-displays] [--happi-cfg HAPPI_CFG]
+ [--fake-device] [--version] [--verbose] [--dark]
+ [--stylesheet-override STYLESHEET_OVERRIDE]
+ [--stylesheet-add STYLESHEET_ADD]
+ [--profile-modules [PROFILE_MODULES ...]]
+ [--profile-output PROFILE_OUTPUT]
+ [--benchmark [BENCHMARK ...]] [--exit-after EXIT_AFTER]
+ [--screenshot SCREENSHOT_FILENAME]
+ [devices ...]
+
+Create a TyphosSuite for device/s stored in a Happi Database
+
+positional arguments:
+ devices Device names to load in the TyphosSuite or class name
+ with parameters on the format:
+ package.ClassName[{"param1":"val1",...}]
+
+optional arguments:
+ -h, --help show this help message and exit
+ --layout LAYOUT Select a alternate layout for suites of many devices.
+ Valid options are "horizontal", "vertical" (default),
+ "grid", "flow", and any unique shortenings of those
+ options.
+ --cols COLS The number of columns to use for the grid layout if
+ selected in the layout argument. This will have no
+ effect for other layouts.
+ --display-type DISPLAY_TYPE
+ The kind of display to open for each device at initial
+ load. Valid options are "embedded" (default),
+ "detailed", "engineering", and any unique shortenings
+ of those options.
+ --scrollable SCROLLABLE
+ Whether or not to include the scrollbar. Valid options
+ are "auto", "true", "false", and any unique
+ shortenings of those options. Selecting "auto" will
+ include a scrollbar for non-embedded layouts.
+ --size SIZE A starting x,y size for the typhos suite. Useful if
+ the default size is not suitable for your application.
+ Example: --size 1000,1000
+ --hide-displays Option to start with subdisplays hidden instead of
+ shown.
+ --happi-cfg HAPPI_CFG
+ Location of happi configuration file if not specified
+ by $HAPPI_CFG environment variable
+ --fake-device Create fake devices with no EPICS connections. This
+ does not yet work for happi devices. An example
+ invocation: typhos --fake-device ophyd.EpicsMotor[]
+ --version, -V Current version and location of Typhos installation.
+ --verbose, -v Show the debug logging stream
+ --dark Use the QDarkStyleSheet shipped with Typhos
+ --stylesheet-override STYLESHEET_OVERRIDE, --stylesheet STYLESHEET_OVERRIDE
+ Override all built-in stylesheets, using this
+ stylesheet instead.
+ --stylesheet-add STYLESHEET_ADD
+ Include an additional stylesheet in the loading
+ process. This stylesheet will take priority over all
+ built-in stylesheets, but not over a template or
+ widget's styleSheet property.
+ --profile-modules [PROFILE_MODULES ...]
+ Submodules to profile during the execution. If no
+ specific modules are specified, profiles all
+ submodules of typhos. Turns on line profiling.
+ --profile-output PROFILE_OUTPUT
+ Filename to output the profile results to. If omitted,
+ prints results to stdout. Turns on line profiling.
+ --benchmark [BENCHMARK ...]
+ Runs the specified benchmarking tests instead of
+ launching a screen. If no specific tests are
+ specified, runs all of them. Turns on line profiling.
+ --exit-after EXIT_AFTER
+ (For profiling purposes) Exit typhos after the
+ provided number of seconds
+ --screenshot SCREENSHOT_FILENAME
+ Save screenshot(s) of all contained
+ TyphosDeviceDisplay instances to this filename pattern
+ prior to exiting early. This name may contain f-string
+ style variables, including: suite_title, widget_title,
+ device, and name.
+
Typhos takes advantage of the flexible data plugin system contained within
+PyDM
and the abstraction of the “control layer” within Ophyd
. In the
+SignalPanel
, objects signals are queried for their type. If these are
+determined to be coming from EPICS
the data plugin configured within
+PyDM
is used directly, any other kind of signal goes through the generic
+SignalPlugin
. This uses the subscription system contained within
+Ophyd
to keep widgets values updated. One caveat is that PyDM
requires
+that channels are specified by a string identifier. In the case of
+ophyd.Signal
objects we want to ensure that these are passed by reference
+to avoid duplicating objects. This means the workflow for adding these has one
+more additonal step where the Signal
is registered with the PyDM
+plugin.
from typhos.plugins import register_signal
+
+# Create an Ophyd Signal
+my_signal = ophyd.Signal(name='this_signal')
+# Register this with the Plugin
+register_signal(my_signal)
+# This signal is now available for use with PyDM widgets
+PyDMWidget(channel='sig://this_signal')
+
Note that this is all done for you if you use the SignalPanel
, but
+maybe useful if you would like to use the SignalPlugin
directly.
In many cases just knowing the value of a signal is not enough to accurately
+display it. Extra pieces of information such as the units and precision of
+information can provide a richer operator experience. Typhos
counts on this
+information being available in the output of describe
method of the signal.
+If you want your child ophyd.Signal
class to convey this information make
+sure that it is expressed properly in the output of describe
.
Metadata |
+Description Key |
+
---|---|
Precision |
+“precision” |
+
Enumeration Strings |
+“enum_strs” |
+
Engineering Units |
+“units” |
+
The PyDM Plugin interface makes no mandate about the type of signals that we
+connect to our widgets. The HappiPlugin
and corresponding
+HappiChannel
contains alternative signals in order to send entire ophyd
+objects from a stored database to our widgets. This is useful where we want to
+populate a template with an entire devices signals instead of connecting
+widgets one by one.
Typhos has two major widgets that users are expected to interface with. The
+first is the TyphosDeviceDisplay
, which shows device information, and
+TyphosSuite
which contains multiple devices and tools. This is the
+barebones implementation. No signals, or widgets are automatically populated in
+the screen. In fact, by default most of the widgets will be hidden. You can
+then manually add signals to the panels and plots, the panels will only show
+themselves when you add PVs.
This suite combines tools and devices into a single widget.
+A ParameterTree
is contained in a QPopBar
+which shows tools and the hierarchy of a device along with options to
+show or hide them.
parent (QWidget, optional)
pin (bool, optional) – Pin the parameter tree on startup.
content_layout (QLayout, optional) – Sets the layout for when we have multiple subdisplays +open in the suite. This will have a horizontal layout by +default but can be changed as needed for the use case.
default_display_type (DisplayType, optional) – DisplayType enum that determines the type of display to open when we +add a device to the suite. Defaults to DisplayType.detailed_screen.
scroll_option (ScrollOptions, optional) – ScrollOptions enum that determines the behavior of scrollbars +in the suite. Default is ScrollOptions.auto, which enables +scrollbars for detailed and engineering screens but not for +embedded displays.
The default tools to use in the suite. In the form of
+{'tool_name': ToolClass}
.
Add an arbitrary widget to the tree of available widgets and tools.
+ +Add an arbitrary widget to the tree of available widgets and tools.
+ +Add a widget to the toolbar.
+Shortcut for:
+suite.add_subdisplay(name, tool, category='Tools')
+
name (str) – Name of tool to be displayed in sidebar
tool (QWidget) – Widget to be added to .ui.subdisplay
Create a new TyphosSuite
from an ophyd.Device
.
device (ophyd.Device) – The device to use.
children (bool, optional) – Choice to include child Device components
parent (QWidget)
tools (dict, optional) – Tools to load for the object. dict
should be name, class pairs.
+By default these will be .default_tools
, but None
can be
+passed to avoid tool loading completely.
pin (bool, optional) – Pin the parameter tree on startup.
content_layout (QLayout, optional) – Sets the layout for when we have multiple subdisplays +open in the suite. This will have a horizontal layout by +default but can be changed as needed for the use case.
default_display_type (DisplayTypes, optional) – DisplayTypes enum that determines the type of display to open when +we add a device to the suite. Defaults to +DisplayTypes.detailed_screen.
scroll_option (ScrollOptions, optional) – ScrollOptions enum that determines the behavior of scrollbars +in the suite. Default is ScrollOptions.auto, which enables +scrollbars for detailed and engineering screens but not for +embedded displays.
show_displays (bool, optional) – If True (default), open all the included device displays. +If False, do not open any of the displays.
**kwargs – Passed to TyphosSuite.add_device()
Create a new TyphosSuite from an iterator of ophyd.Device
device (ophyd.Device)
children (bool, optional) – Choice to include child Device components
parent (QWidget)
tools (dict, optional) – Tools to load for the object. dict
should be name, class pairs.
+By default these will be .default_tools
, but None
can be
+passed to avoid tool loading completely.
pin (bool, optional) – Pin the parameter tree on startup.
content_layout (QLayout, optional) – Sets the layout for when we have multiple subdisplays +open in the suite. This will have a horizontal layout by +default but can be changed as needed for the use case.
default_display_type (DisplayTypes, optional) – DisplayTypes enum that determines the type of display to open when +we add a device to the suite. Defaults to +DisplayTypes.detailed_screen.
scroll_option (ScrollOptions, optional) – ScrollOptions enum that determines the behavior of scrollbars +in the suite. Default is ScrollOptions.auto, which enables +scrollbars for detailed and engineering screens but not for +embedded displays.
show_displays (bool, optional) – If True (default), open all the included device displays. +If False, do not open any of the displays.
**kwargs – Passed to TyphosSuite.add_device()
Get a subdisplay by name or contained device.
+widget – Widget that is a member of the ui.subdisplay
QWidget or partial
+Example
+suite.get_subdisplay(my_device.x)
+suite.get_subdisplay('My Tool')
+
Hide a visible subdisplay.
+widget (SidebarParameter or Subdisplay) – If you give a SidebarParameter, we will find the corresponding +widget and hide it. If the widget provided to us is inside a +DockWidget we will close that, otherwise the widget is just hidden.
+Save suite settings to a file using typhos.utils.save_suite()
.
A QFileDialog
will be used to query the user for the desired
+location of the created Python file
The template will be of the form:
+import sys
+import typhos.cli
+
+devices = {devices}
+
+def create_suite(cfg=None):
+ return typhos.cli.create_suite(devices, cfg=cfg)
+
+if __name__ == '__main__':
+ typhos.cli.typhos_cli(devices + sys.argv[1:])
+
Save screenshot(s) of devices to filename_format
.
Open a display in the dock system.
+widget (QWidget, SidebarParameter or str) – If given a SidebarParameter
from the tree, the widget will be
+shown and the sidebar item update. Otherwise, the information is
+passed to get_subdisplay()
widget – The subdisplay that was shown.
+QWidget
+Tools loaded into the suite.
+Get top-level groups.
+This is of the form:
+{'name': QGroupParameterItem}
+
Main display for a single ophyd Device.
+This contains the widgets for all of the root devices signals, and any
+methods you would like to display. By typhos convention, the base
+initialization sets up the widgets and the from_device()
class
+method will automatically populate the resulting display.
parent (QWidget, optional) – The parent widget.
scrollable (bool, optional) – Semi-deprecated parameter. Use scroll_option instead.
+If True
, put the loaded template into a QScrollArea
.
+If False
, the display widget will go directly in this widget’s
+layout.
+If omitted, scroll_option is used instead.
embedded_templates (list, optional) – List of embedded templates to use in addition to those found on disk.
detailed_templates (list, optional) – List of detailed templates to use in addition to those found on disk.
engineering_templates (list, optional) – List of engineering templates to use in addition to those found on +disk.
display_type (DisplayTypes, str, or int, optional) – The default display type.
scroll_option (ScrollOptions, str, or int, optional) – The scroll behavior.
nested (bool, optional) – An optional annotation for a display that may be nested inside another.
alias of DisplayTypes
Add a Device and signals to the TyphosDeviceDisplay.
+The full dictionary of macros is built with the following order of +precedence:
+1. Macros from the device metadata itself.
+2. If available, `name`, and `prefix` will be added from the device.
+3. The argument ``macros`` is then used to fill/update the final
+ macro dictionary.
+
This will also register the device’s signals in the sig:// plugin. +This means that any templates can refer to their device’s signals by +name.
+device (ophyd.Device) – The device to add.
macros (dict, optional) – Additional macros to use/replace the defaults.
Get the current template being displayed.
+Get the device associated with this Device Display.
+Get the full class with module name of loaded device.
+Get the name of the loaded device.
+Get or set the current display type.
+Get the widget generated from the template.
+Force a specific template.
+Create a new TyphosDeviceDisplay from a Device class.
+Loads the signals in to the appropriate positions and sets the title to +a cleaned version of the device name.
+Create a new TyphosDeviceDisplay from a Device.
+Loads the signals in to the appropriate positions and sets the title to +a cleaned version of the device name
+ +Get the best template for the given display type.
+ +Toggle hiding or showing empty panels.
+Get or set the macros for the display.
+Place the display in a scrollable area.
+Standardized Typhos Device Display title.
+title (str, optional) – The initial title text, which may contain macros.
show_switcher (bool, optional) – Show the TyphosDisplaySwitcher
.
show_underline (bool, optional) – Show the underline separator.
parent (QtWidgets.QWidget, optional) – The parent widget.
Typhos callback: set the TyphosDeviceDisplay
.
Get or set whether to show the display switcher.
+Get or set whether to show the underline.
+Display switcher set of buttons for use with a TyphosDeviceDisplay.
+ + + + +Typhos hook for setting the associated device display.
+Normalize a given display type.
+display_type (DisplayTypes, str, or int) – The display type.
+display_type – The normalized DisplayTypes
.
DisplayTypes
+ValueError – If the input cannot be made a DisplayTypes
.
Recursively hide empty panels and widgets.
+widget (QWidget) – The widget in which to start the recursive search.
process_widget (bool) – Whether or not to process the visibility for the widget. +This is useful since we don’t want to hide the top-most +widget otherwise users can’t change the visibility back on.
Recursively shows all panels and widgets, empty or not.
+widget (QWidget)
+Toggle the visibility of all TyphosSignalPanel
in a display.
widget (QWidget) – The widget in which to look for Panels.
force_state (bool) – If set to True or False, it will change visibility to the value of +force_state. +If not set or set to None, it will flip the current panels state.
Typhos Logging Display.
+ + +Methods
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+Add a device to the logging display. |
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+Create a new instance of the widget for a Device |
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+Each keyword argument is either the name of a Qt property or a Qt signal. |
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
Attributes
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+QPoint) [signal] |
+
|
+Optional[QObject] = None) [signal] |
+
|
+Optional[str]) [signal] |
+
|
++ |
|
+QIcon) [signal] |
+
|
+Optional[str]) [signal] |
+
|
+Optional[str]) [signal] |
+
Generalized widget for plotting Ophyd signals.
+This widget is a TimeChartDisplay
wrapped with some convenient
+functions for adding signals by their attribute name.
parent (QWidget)
+Methods
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+Add an Ophyd signal to the list of available channels. |
+
|
+Add a curve to the plot. |
+
|
+Add a device and it's component signals to the plot. |
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+Reaction to |
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+Create a new instance of the widget for a Device |
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+Each keyword argument is either the name of a Qt property or a Qt signal. |
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+Remove a curve from the plot. |
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
Attributes
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
+A dictionary of channel_name to curve. |
+
|
+QPoint) [signal] |
+
|
+Optional[QObject] = None) [signal] |
+
|
+Optional[str]) [signal] |
+
|
++ |
|
+QIcon) [signal] |
+
|
+Optional[str]) [signal] |
+
|
+Optional[str]) [signal] |
+
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
EPICS is a flexible and powerful controls system to access to experimental +information, however, the relation and meaning of process variables is often +obscure. Many of the user interfaces for EPICS information reflect this, as +walls of buttons and flashing lights bombard the user with little thought to +structure or cohesion.
+Typhos addresses this by providing an automated way to generate screens based +on a provided hierarchy of devices and signals. Built using PyDM, a PyQt based +display manager developed at SLAC National Laboratory, Typhos utilizes a large +toolkit of widgets to display EPICS information. For each process variable, a +corresponding widget is created based on; the importance to the average +operator, the type of value the EPICS PV will return, and whether a user should +be allowed to write to the variable. These widgets are then placed in a +convenient tab-based system to only show the necessary information for basic +function, but still allow access to more advanced signals.
+Instead of reinventing a new way to specify device structures, Typhos uses +Ophyd, a library to abstract EPICS information into consistently structured +Python objects. Originally built for scripting experimental procedures at +NSLS-II, Ophyd represents devices as combinations of components which are +either signals or nested devices. Then, either at runtime or by using the +defaults of the representative Python class, these signals are sorted into +different categories based on their relevance to operators. Typhos uses this +information to craft user interfaces.
+ +Add a new Signal to the registry.
+The Signal object is kept within signal_registry
for reference by name
+in the SignalConnection
. Signals can be added multiple times,
+but only the first register_signal call for each unique signal name
+has any effect.
Signals can be referenced by their name
attribute or by their
+full dotted path starting from the parent’s name.
Connection to monitor an Ophyd Signal.
+This is meant as a generalized connection to any type of Ophyd Signal. It +handles reporting updates to listeners as well as pushing new values that +users request in the PyDM interface back to the underlying signal.
+The signal data_type is used to inform PyDM on the Python type that the +signal will expect and emit. It is expected that this type is static +through the execution of the application.
+Stored signal object.
+ophyd.Signal
+Add a listener channel to this connection.
+This attaches values input by the user to the send_new_value function +in order to update the Signal object in addition to the default setup +performed in PyDMConnection.
+Cast a value to the correct Python type based on signal_type
.
If signal_type
is not set, the result of ophyd.Signal.describe
+is used to determine what the correct Python type for value is. We need
+to be aware of the correct Python type so that we can emit the value
+through the correct signal and convert values returned by the widget to
+the correct type before handing them to Ophyd Signal.
Find a signal in the registry given its address.
+This method is intended to be overridden by subclasses that +may use a different mechanism to keep track of signals.
+address – The connection address for the signal. E.g. in +“sig://sim_motor.user_readback” this would be the +“sim_motor.user_readback” portion.
+The Ophyd signal corresponding to the address.
+Signal
+Pass a value from the UI to Signal.
+We are not guaranteed that this signal is writeable so catch exceptions
+if they are created. We attempt to cast the received value into the
+reported type of the signal unless it is of type np.ndarray
.
Remove a listener channel from this connection.
+This removes the send_new_value connections from the channel in +addition to the default disconnection performed in PyDMConnection.
+Update the UI with new metadata from the Signal.
+Signal metadata updates always send all available metadata, so +default values to this function will not be sent ever if the signal +has valid data there.
+We default missing metadata to None and skip emitting in general, +but for severity we default to NO_ALARM for UI purposes. We don’t +want the UI to assume that anything is in an alarm state.
+These functions will not be imported if happi
is not installed in the
+current Python environment
Register a Happi Client to be used with the DataPlugin.
+This is not required to be called by the user, if your environment is setup
+such that happi.Client.from_config()
will return the desired client.
+ t | ||
+ |
+ typhos | + |
+ |
+ typhos.cli | + |
+ |
+ typhos.utils | + |
Each TyphosDeviceDisplay
has an method_panel
. You can add methods
+manually or pass them in via the constructor. In order to make the appropriate
+widgets, the function signature is examined. For example lets make a mock
+function:
def foo(a: int, b: int, c: bool=False, d: float=3.14, e: bool=False):
+ pass
+
When you add the method to the panel the Python inspect
module looks for
+type annotations for each parameter. It also determines which parameters are
+optional and which are not. Boolean variables are given QCheckboxes, while
+others are given QLineEdits for entry. Optional keywords are also hidden from
+the user unless they choose to expand the tab. Using
+FunctionPanel.add_method()
would look like this:
panel.add_method(foo, hide_params=['e'])
+
If you don’t want to annotate your function as above, Typhos will attempt to +guess the type of optional variables via their default value. You can also pass +in an annotations dictionary that fulfills the indicates the type of each +variable.
+TyphosStatusThread
now has a dramatically different signal API.
+This is an improved version but if you were using this class take note
+of the changes. Key member signals are:
+- TyphosStatusThread.status_started
+- TyphosStatusThread.status_timeout
+- TyphosStatusThread.status_finished
+- TyphosStatusThread.error_message
+- TyphosStatusThread.status_exc
Rework the design, sizing, and font scaling of the positioner row widget to address +concerns about readability and poor use of space for positioners that don’t need +all of the widget components.
Implement dynamic resizing in all directions for positioner row widgets.
Make the timeout messages friendlier and more accurate when the
+timeouts come from the TyphosPositionerWidget
.
Make error messages in general (including status timeouts) clearer
+when they come from the positioner device class controlled by the
+TyphosPositionerWidget
.
Fix an issue where the row positioner widget’s resizing would peg the cpu to 100%
Fix various issues that cause font clipping for specific motors using the positioner row widget.
Fix various issues with enum handling in the SignalPlugin.
Fix issues with cloud-only CI failures and segfaults.
unpin jinja, sphinx no longer incompatible
Refactor TyphosStatusThread
to facilitate timeout message changes.
In dev/test requirements, pin pcdsdevices to current latest to fix the CI builds.
canismarko
tangkong
zllentz
Change position widget limit colors from yellow off/yellow on to +dark gray off/orange on for better contrast.
zllentz
Add overridable find_signal
method to the SignalConnection
class to allow
+the use of alternate signal registries in subclasses.
Updated look and feel: change the typhos suite cli defaults to +“embedded” displays arranged “vertically” instead of +“detailed” displays arranged “horizontally” +to more naturally match how typhos is used at present.
canismarko
zllentz
The deprecated TyphosConsole
has been removed as discussed in issue #538.
TyphosDeviceDisplay
composite heuristics have been removed in favor of
+simpler methods, described in the features section.
The packaged IOC for benchmark testing is now in typhos.benchmark.ioc
.
Added typhos --screenshot filename_pattern
to take screenshots of typhos
+displays prior to exiting early (in combination with --exit-after
).
Added TyphosSuite.save_screenshot
which takes a screenshot of the entire
+suite as-displayed.
Added TyphosSuite.save_device_screenshots
which takes individual
+screenshots of each device display in the suite and saves them to the
+provided formatted filename.
LazySubdisplay.get_subdisplay
now provides the option to only get
+existing widgets (by using the argument instantiate=False
).
TyphosNoteEdit
now supports .add_device()
like other typhos widgets.
+This is alongside its original setup_data
API.
TyphosNoteEdit
is now a TyphosBase
object and is accessible in the Qt
+designer.
Added new designable widget TyphosPositionerRowWidget
. This compact
+positioner widget makes dense motor-heavy screens much more space efficient.
The layout method for TyphosDeviceDisplay
has changed. For large device trees,
+it now favors showing the compact “embedded” screens over detailed screens. The order
+of priority is now as follows:
For top-level devices (e.g., at2l0
), the template load priority is as follows:
Happi-defined values ("detailed_screen"
, embedded_screen"
, "engineering_screen"
)
Device-specific screens, if available (named as ClassNameHere.detailed.ui
)
The detailed tree, if the device has sub-devices
The default templates
For nested displays in a device tree, sub-device (e.g., at2l0.blade_01
)
+template load priority is as follows:
Device-specific screens, if available (named as ClassNameHere.embedded.ui
)
The detailed tree, if the device has sub-devices
The default templates (embedded_screen.ui
)
Increase motor timeouts proportionally for longer moves.
Added dynamic font sizer utility which can work with some Qt-provided widgets +as well as PyDM widgets.
Qt object names for displays will now be set automatically to aid in +debugging.
Fix an issue where setpoint widgets in the full positioner +widget had become zero-width.
Creates new notes file if requested note file does not exist
Typhos suites will now resize in width to fit device displays.
For devices which do not require keyword arguments to instantiate, the typhos
+CLI will no longer require an empty dictionary. That is, $ typhos
+ophyd.sim.SynAxis[]
is equivalent to $ typhos ophyd.sim.SynAxis[{}]
.
+As before, ophyd’s required “name” keyword argument is filled in by typhos by
+default.
Fix an issue where ophyd signals with floats would always display with a +precision of 0 without special manual configuration. Floating-point signals +now default to a precision of 3.
Fix issues with running the CLI benchmarks in certain +conda installs, particularly python>=3.10.
ophyd.Kind
usage has been fixed for Python 3.11. Python 3.11 differs in
+enumeration of IntFlag
items, resulting in typhos only picking up
+component kinds that were a power of 2.
multiprocessing
is no longer used to spawn the test suite benchmarking
+IOC, as it was problematic for Python 3.11. The provided IOC is now spawned
+using the same utilities provided by the caproto test suite.
Vendored pydm load_ui_file
and modified it so we can always get our
+Display
instance back in TyphosDeviceDisplay
.
Ignore deleted qt objects on SignalConnection.remove_connection
, avoiding
+teardown error tracebacks.
Avoid creating subdisplays during a call to TyphosSuite.hide_subdisplays
Added a pytest hook helper to aid in finding widgets that were not cleaned
Avoid failing screenshot taking when widgets are garbage collected at the +same time.
Avoid race condition in description cache if the cache is externally cleared +when a new description callback is received.
Avoid uncaught TypeError
when None
is present in a positioner
+.limits
.
adds TyphosDisplaySwitcher to TyphosPositionerRowWidget
adds checklist to Pull Request Template
Add pre-release notes scripts
Update build requirements to use pip-provided extras for documentation and test builds
Update PyDM pin to >=1.19.1 due to Display method being used.
Avoid hundreds of warnings during line profiling profiling by intercepting +messages about profiling the wrapped function instead of the wrapper.
The setpoint history menu on TyphosLineEdit
is now only created on-demand.
klauer
tangkong
zllentz
This is a bugfix and maintenance/CI release.
+Include the normal PyDM stylesheets in the loading process. +Previously, this was leading to unexpected behavior.
Fix an issue related to a deleted flake8 mirror.
Migrates from Travis CI to GitHub Actions for continuous integration testing, and documentation deployment.
Updates typhos to use setuptools-scm, replacing versioneer, as its version-string management tool of choice.
Syntax has been updated to Python 3.9+ via pyupgrade
.
typhos has migrated to modern pyproject.toml
, replacing setup.py
.
Sphinx 6.0 now supported for documentation building.
tangkong
zllentz
This is a small release with features for improving the usage
+and configurability of the PositionerWidget
.
Report errors raised during the execution of positioner
+set
commands in the positioner widget instead of in a pop-up.
+This makes it easier to keep track of which positioner widget
+is associated with which error and makes it less likely that the
+message will be missed or lost on large monitors.
Add a designer property to PositionerWidget
, alarmKindLevel
,
+to configure the enclosed alarm widget’s kindLevel
property in
+designer. This was previously only configurable in code.
zllentz
This is a small release with bugfixes and maintenance.
+Do not wait for lazy signals when creating a SignalPanel. +This was causing long setup times in some applications.
Call stop with success=True in the positioner widget to avoid causing +our own UnknownStatusError, which was then displayed to the user.
Add cleanup for background threads.
Add replacement for functools.partial usage in methods as +this was preventing TyphosSuite from getting garbage collected.
Removes custom designer widget plugin, +instead relying on PyDM’s own mechanism
Use pydm’s data plugin entrypoint to include the sig and happi channels.
Prevent TyphosStatusThread objects from being orphaned.
klauer
tangkong
zllentz
This is a bugfix and maintenance release.
+Fix various instances of clipping in the positioner widget.
Show Python documentation when no web help is available.
Fix issues with suite sidebar width.
Lazy load all tools to improve performance.
Fix the profiler to also profile class methods.
Use cached paths for finding class templates.
Properly handle various deprecations and deprecation warnings.
Fix usage of deprecated methods in happi (optional dependency).
Log “unable to add device” without the traceback, which was previously unhelpful.
Pin pyqt at 5.12 for test suite incompatibility in newer versions.
Ensure that test.qss test suite artifact is cleaned up properly.
Fix the broken test suite.
Pin jinja2 at <3.1 in CI builds for sphinx <4.0.0 compatibility
anleslac
klauer
zllentz
This is a small bugfix release.
+Fix an issue where the configuration menu would be defunct for +custom template screens.
Add some additional documentation about sig:// and cli usage.
Configure and satisfy the repository’s own pre-commit checks.
Update versioneer install to current latest.
klauer
zllentz
This is a small release with fixes and features that were implemented +last month.
+Add the option to hide displays in the suite at launch, +rather than automatically showing all of them.
Allow the sig:// protocol to be used in typhos templates by +automatically registering all of a device’s signals at launch.
Fix an issue where an assumption about the nature of EpicsSignal +object was breaking when using PytmcSignal objects from pcdsdevices.
Make a workaround for a C++ wrapped exception that could happen +in specific orders of loading and unloading typhos alarm widgets.
This is a small bugfix release that was deployed as a hotfix +to avoid accidental moves.
+Disable scroll wheel interaction with positioner combo boxes. +This created a situation where operators were accidentally +requesting moves while trying to scroll past the control box. +This was previously fixed for the typhos combo boxes found on +the various automatically generated panels in v1.1.0, but not +for the positioner combo boxes.
This is a feature and bugfix release to extend the customizability of +typhos suites and launcher scrips, to fix various issues in control +layer and enum handling, and to do some necessary CI maintenance.
+Add suite options for layouts, display types, scrollbars, and +starting window size. These are all also available as CLI arguments, +with the intention of augmenting typhos suite launcher scripts. +Here are some examples:
+--layout grid --cols 3
: lays out the device displays in a 3-column
+grid
--layout flow
: lays out the device displays in a grid that adjusts
+dynamically as the window is resized.
--display-type embed
: starts all device displays in their embedded
+state
--size 1000,1000
: sets a starting size of 1000 width, 1000 height for
+the suite window.
See #450
+Respect ophyd signal enum_strs and metadata updates. Previously, these were +ignored, but these can update during the lifetime of a screen and should be +used. (#459)
Identify signals that use non-EPICS control layers and handle them +appropriately. Previously, these would be misidentified as EPICS signals +and handled using the ca:// PyDM plugin, which was not correct. +(#463)
Fix an issue where get_native_methods could fail. This was not observed +in the field, but it broke the test suite. +(#464)
Fix various issues related to the test suite stability.
This is a minor feature release of typhos.
+Added option to pop out documentation frame +(#458)
Fixed authorization headers on Typhos help widget redirect +(#457)
+This allows for the latest Confluence to work with Personal +Access Tokens while navigating through the page
This is a feature update with backwards-incompatible changes, namely the +removal and relocation of the LCLS typhos templates.
+All device templates except for the PositionerBase
template have been
+moved from typhos to pcdsdevices, which is where their device classes
+are defined. This will break LCLS environments that update typhos without
+also updating pcdsdevices, but will not affect environments outside of LCLS.
Add the TyphosRelatedSuiteButton
, a QPushButton
that will open a device’s
+typhos screen. This can be included in embedded widgets or placed on
+traditional hand-crafted pydm screens as a quick way to open the typhos
+expert screen.
Add the typhos help widget, which is a new addition to the display switcher
+that is found in all built-in typhos templates. Check out the ?
button!
+See the docs for information on how to configure this.
+The main features implemented here are:
View the class docstring from inside the typhos window
Open site-specific web documentation in a browser
Report bugs directly from the typhos screen
Expand the PositionerWidget
with aesthetic updates and more features:
Show driver-specific error messages from the IOC
Add a “clear error” button that can be linked to IOC-specific error
+reset routines by adding a clear_error
method to your positioner
+class. This will also clear status errors returned from the positioner’s
+set routine from the display.
Add a moving/done_moving indicator (for EpicsMotor
, uses the .MOVN
field)
Add an optional TyphosRelatedSuite
button
Allow the stop
button to be removed if the stop
method is missing or
+otherwise raises an AttributeError
on access
Add an alarm indicator
Add the typhos.ui
entry point. This allows a module to notify typhos that
+it should check specified directories for custom typhos templates. To be
+used by typhos, the entry point should load a str
, pathlib.Path
, or list
+of such objects.
Move the examples submodule into the typhos.examples
submodule, so we can
+launch the examples by way of e.g. typhos -m typhos.examples.positioner
.
For the alarm indicator widgets, allow the pen width, pen color, and +pen style to be customized.
Find a better fix for the issue where the positioner combobox widget would
+put to the PV on startup and on IOC reboot
+(see v1.1.0
note about a hacky workaround).
Fix the issue where the positioner combobox widget could not be used to +move to the last position selected.
Fix an issue where a positioner status that was marked as failed immediately +would show as an unknown error, even if it had an associated exception +with useful error text.
Add documentation for all features included in this update
Add documentation for how to create custom typhos
templates
This is a feature update intended for use in lucid, but it may also be useful +elsewhere.
+Add a handful of new widgets for indicating device alarm state. These will +change color based on the most severe alarm found among the device’s signals. +Their shapes correlate with the available shapes of PyDMDrawingWidget:
+TyphosAlarmCircle
TyphosAlarmRectangle
TyphosAlarmTriangle
TyphosAlarmEllipse
TyphosAlarmPolygon
Add a sigint handler to avoid annoying behavior when closing with Ctrl-C on +macOS.
Increase some timeouts to improve unit test consistency.
This is maintenance/compatibility release for pydm v1.11.0.
+Internal fixes regarding error handling and input sanitization. +Some subtle issues cropped up here in the update to pydm v1.11.0.
Fix issue where the test suite would freeze when pydm displays +an exception to the user.
This is a maintenance release
+Fix an issue where certain data files were not included in the package +build.
This is a bugfix release
+Fix returning issue where certain devices could fail to load with a +“dictionary changed during iteration” error.
Fix issue where the documentation was not building properly.
This is a minor screen inclusion release.
+Add a screen for AT1K4. This, and similar screens, should be moved out of +typhos and into an LCLS-specific landing-zone, but this is not ready yet.
This is a minor bugfix release.
+Fix issue where SignalRO
from ophyd
was not showing as read-only.
Update the AT2L0 screen to not have a redundant calculation dialog as per +request.
This is a bugfix release. Please use this instead of v1.1.0.
+Fix issue with ui files not being included in the manifest
Fix issue with profiler failing on tests submodule
This is a big release with many fixes and features.
+Make Typhos aware of variety metadata and assign appropriate widgets based +on the variety metadata assigned in pcdsdevices.
Split templates into three categories: core, devices, and widgets. +Core templates are the main typhos display templates, e.g. detailed_tree. +Devices templates are templates tailored for specific device classes. +Widgets templates define special typhos widgets like tweakable, positioner, +etc.
Add attenuator calculator screens. These may be moved to another repo in a +future release.
Add information to loading widgets indicating timeout details.
Fix issue with comboboxes being set on mouse scroll.
Allow loading classes from cli with numbers in the name.
Fix issue with legacy codepath used in lightpath.
Fix issue with widget UnboundLocalError.
Hacky workaround for issue with newer versions of Python.
Hacky workaround for issue where positioner widget puts on startup.
Fix issue with unset _channel member.
Fix issue with typhos creating and installing a tests package separate +from the main typhos package.
Add variety testing IOC.
Add doctr_versions_menu extension to properly render version menu.
Fix issues with failing benchmark tests
A bug fix and package maintenance release.
+PositionerWidget moves set their timeouts based on expected +velocity and acceleration, rather than a flat 10 seconds.
Ensure that widgets with no layout or minimum size are still displayed.
Update local conda recipe to match conda-forge.
Update CI to used shared configurations.
A bug fix release with a minor addition.
+TyphosLoading now takes in a timeout value to switch the animation +with a text message stating that the operation timed-out after X +seconds.
Combobox widgets were appearing when switching or refreshing templates.
A major new feature release with added views for complex devices and +simplified configurability.
+As planned, the deprecated import name typhon
and the typhon
+command-line tool have been removed.
Panels: New TyphosCompositeSignalPanel
, which composes multiple
+TyphosDisplay
s in a tree-like view.
Benchmarking: new profiling tools accessible in the command-line
+typhos
tool, allowing for per-line profiling of standardized
+devices. (--benchmark
)
Template discovery: templates are discovered based on screen macros +and class inheritance structure, with the fallback of built-in +templates.
New command-line options for testing with mock devices
+(--fake-device
).
Performance: Major performance improvements by way of background +threading of signal description determination, display path caching, +and connection status monitoring to reduce GUI thread blocking.
Display: Adds a “display switcher” tool for easy access to different +screen types.
Display: Adds a “configuration” button to displays.
Filtering: Filter panel contents by kinds.
Filtering: Filter panel contents by signal names.
Setpoint history: a history of previous setpoints has been added to
+the context menu in TyphosLineEdit
.
Positioner widgets have been redesigned to be less magical and more fault- +tolerant. Adds designable properties that allow for specification of +attribute names.
Anything that inherits from PositionerBase
will have the template as an
+option (EpicsMotor
, PCDSMotorBase
, etc.)
Reworked default templates to remove the miscellaneous
panel. Omitted
+signals may still be shown by way of panel context menus or configuration
+menus.
Python 3.8 is now being included in the test suite.
Happi is now completely optional.
Popped-out widgets such as plots will persist even when the parent +display is closed.
Font sizes should be more consistent on various DPI displays.
Module typhos.signal
has been renamed to typhos.panel
.
TyphosTimePlot
no longer automatically adds signals to the plot.
Removed internally-used typhos.utils.grab_kind
.
OSX layout of TyphosSuite
should be improved using the unified title and
+toolbar.
Fix docs deployment
Add “loading in progress” gif
Fix sorting of signals
Automatically choose exponential format based on engineering units
Fix lazy loading in ophyd 1.4
Save images of widgets when running tests
Add a new “PopBar” which pops in the device tree in the suite
Clean up the codebase - sort all imports + fix style
Relocate SignalRO to a single spot
This release is dedicated to the renaming of the package from Typhon
+to Typhos
. The main reason for the renaming is a naming conflict at
+PyPI that is now addressed.
This release is still compatible and will throw some DeprecationWarnings
+when typhon
is used. The only incompatible piece is for Qt
+Stylesheets. You will need to add the typhos
equivalents to your
+custom stylesheets if you ever created one.
This is the first release with the backwards compatibility for typhon. +In two releases time it will be removed.
+It was a long time since the latest release of Typhon
. It is time
+for a new one. Next releases will have again the beautiful and
+descriptive messages for enhancements, bug fixes and etc.
A lot.
+This is a minor release of the Typhon
library. No major features
+were added, but instead the library was made more stable and utilitarian
+for use in other programs. This includes making sure that any calls to a
+signal’s values or metadata are capable of handling disconnections. It
+also moves some of the methods that were hidden in larger classes or
+functions into smaller, more useful methods.
SignalPlugin
now transmits all the metadata that is guaranteed to
+be present from the base Signal
object. This includes
+enum_strs
, precision
, and units
+(#92)
DeviceDisplay
now has an optional argument children
. This
+makes it possible to ignore a Device
components when creating the
+display (#96)
The following utility functions have been created to ensure that a
+uniform approach is taken forDevice
introspection:
+is_signal_ro
, grab_hints
+(#98)
The title
command in SignalPanel
was no longer used. It is
+still accepted in this release, but will dropped in the next major
+release (#90)
This Typhon
release marks the transition from prototype to a stable
+library. There was a variety of API breaks and deprecations after
+v0.1.0
as many of the names and functions were not future-proof.
Typhon
is now available on the pcds-tag
Anaconda channel
+(#45)
Typhon
now installs a special data plugin for PyDM
called
+SignalPlugin
. This uses the generic ophyd.Signal
methods to
+communicate information to PyDM widgets.
+(#63)
Typhon
now supports two different stylesheets a “light” and
+“dark” mode. These are not activated by default, but instead can be
+accessed via use_stylesheet
function
+(#61,
+#89)
There is now a sidebar to the DeviceDisplay
that makes adding
+devices and tools easier. The add_subdisplay
function still works
+but it is preferable to use the more specific add_tool
and
+add_subdevice
.
+(#61)
Typhon
will automaticaly create a PyDMLogDisplay
to show the
+output of the logging.Logger
object attached to each
+ophyd.Device
+(#70)
Typhon
now creates a PyDMTimePlot
with the “hinted”
+attributes of the Device. This can be configured at runtime to have
+fewer or more signals
+(#73)
All of the Panel
objects have been moved to different files.
+SignalPanel
now resides in typhon.signal
while the base
+Panel
that is no longer used to display signals is in the generic
+typhon.widgets
renamed as TogglePanel
+(#50)
TyphonDisplay
requires ophyd >= 1.2.0
. The PyDMLogDisplay
+tool is attached to the Device.log
that is now present on all
+ophyd
devices.
+(#53)
pydm >= 1.2.0
due to various bug fixes and widget additions
+(#63)
QDarkStyleSheet
is now included in the recipe to provide dark
+stylesheet support.
+(#89)
The initial release of Typhon. This serves as a proof of concept for the +automation of PyDM screen building as informed by the structure of an +Ophyd Device.
+Generate a full DeviceDisplay
with all of the device signals and
+sub-devices available
Include methods from the ophyd Device in the User Interface, +automatically parse the arguments to make a widget representation of +the function
Include png
images associated with devices and sub-devices
TyphosSuite
objects can be stored for later use. The devices that
+were loaded into the suite via TyphosSuite.add_device()
will be added
+once again assuming that they are stored in a happi
database.
Save suite settings to a file using typhos.utils.save_suite()
.
A QFileDialog
will be used to query the user for the desired
+location of the created Python file
The template will be of the form:
+import sys
+import typhos.cli
+
+devices = {devices}
+
+def create_suite(cfg=None):
+ return typhos.cli.create_suite(devices, cfg=cfg)
+
+if __name__ == '__main__':
+ typhos.cli.typhos_cli(devices + sys.argv[1:])
+
There are two major ways to use this created file:
+Execute the Python file from the command line. This will route the call
+through the standard typhos.cli
meaning all options
+described there are also available.
$ python saved_suite.py
+
The create_suite
method generated in the saved file can be used to
+re-create the TyphosSuite
in an already running Python process.
+Typhos provides the load_suite()
function to import the provided
+Python file and execute the stored create_suite
method. This is useful
+if you want to use the file to embed a saved TyphosSuite
inside
+another PyQt window for instance, or load multiple suites at once.
from qtpy.QtWidgets import QApplication
+from typhos import load_suite
+
+app = QApplication([])
+saved_suite = load_suite('saved_suite.py')
+
+saved_suite.show()
+app.exec_()
+
Note
+The saved file only stores a reference to the devices loaded into the
+TyphosSuite
by name. It is assumed that these devices will be available
+under the same name via the configured happi
database when
+load_suite
is called. If the device has a different name in the database
+or you have configured a different happi
database to be used your
+devices will not be loaded properly.
Typhos ships with a handful of built-in templates. You can see these when you
+browse the typhos/ui/core
and typhos/ui/devices
directories.
Note
+This repo originally had a large number of LCLS-specific device templates. +These have been moved to pcdsdevices.ui.
+You can define your own templates outside of typhos to customize the behavior +of the module when launching screens. These can be done generically, to +replace the default templates, or per-class, to replace the templates in +specific cases.
+Templates are .ui
files created in qt designer. These are largely just
+normal pydm displays, with extra macro substitutions. See the
+pydm tutorial
+for more guidance on using the designer.
All the information found in happi
will be loaded as a pydm macro into the
+template. It does this by checking for attributes on the device.md
+namespace object.
If no device.md
object is found, we will still include device.name
+as the name
macro and device.prefix
as the prefix
macro.
The upshot of this is that you can include ${name}
, ${prefix}
, and
+other keys from the happi database in your template and they will be
+filled in from the device database on load.
To replace a default template, create a template with exactly the same name.
+To create a template for a class, name it based on the class name +and the template type, e.g.:
+PositionerBase.embedded.ui
PositionerBase.detailed.ui
PositionerBase.engineering.ui
Note that we’ll check an object class’s mro() when deciding which template to +use- this is why all PositionerBase subclasses use the built-in +PositionerBase.detailed.ui template by default.
+In this way you can create one template for a set of related classes.
+There are currently three places that typhos checks for templates. +In order of priority:
+Check the paths defined by the PYDM_DISPLAYS_PATH
environment variable.
Check any paths defined by the typhos.ui
package entry point.
Check the built-in paths (core and devices)
With that in mind, there are two recommended workflows for running typhos with +custom templates:
+Create a repository to store your screens, and set PYDM_DISPLAYS_PATH
+to point to your repository clone’s screens directory. This path works
+exactly like any other PATH
variable in linux.
Create a module that defines the typhos.ui
entry point. This entry
+point is expecting to find a str
, pathlib.Path
, or list
of
+such objects at your entry point. One such example of how to do this can
+be found here
For top-level devices (e.g., at2l0
), the template load priority is as
+follows:
Happi-defined values ("detailed_screen"
, embedded_screen"
,
+"engineering_screen"
)
Device-specific screens, if available (named as ClassNameHere.detailed.ui
)
The detailed tree, if the device has sub-devices
The default templates
For nested displays in a device tree, sub-device (e.g., at2l0.blade_01
)
+template load priority is as follows:
Device-specific screens, if available (named as ClassNameHere.embedded.ui
)
The detailed tree, if the device has sub-devices
The default templates
In experimental environments there are a variety of external tools and
+applications that are critical to day to day operation. Typhos hopes to
+integrate many of these services into the TyphosDeviceDisplay
for
+ease of operation. This approach has two advantages; the first is that getting
+to helpful tools requires fewer clicks and therefore less time, secondly, if we
+assume that the context in which they want to use the external tool includes
+this device, we can pre-populate many of the fields for them.
All of the tools in typhos
follow a basic pattern. Each one can be
+instantiated as a standalone widget with no ophyd
or Device
required. The
+intention is that these tools could be used in a separate application where the
+underlying information is in a different form. However, in order to make these
+objects easier to interface with ophyd
objects the methods
+TyphosTool.from_device()
and TyphosTool.add_device()
are
+available. These automatically populate fields according to device structures.
|
+Typhos Logging Display. |
+
|
+Generalized widget for plotting Ophyd signals. |
+
Typhos, from time to time, has had issues with its unit tests. +These often manifest as test failures and segmentation faults that only occur +when running the tests on a cloud platform.
+By and large, these are related to difficulty with cleaning up resources from +tests that allocate qt widgets.
+Always use the qtbot
fixture (from the pytest-qt
package)
Always call qtbot.add_widget(widget)
on any widget you create in your test.
+This helps clean up your widget after the test is complete.
Use the qapp
fixture and call qapp.processEvents()
if you need “something”
+in the qt world to happen.
Use the noapp
fixture if you need to test code that calls qapp.exec_()
or
+qapp.exit()
. Calling this code with no fixture will break the test suite for
+all future tests than need the qapp
.
If your test is segfaulting, try using the @pytest.mark.no_gc
decorator
+to skip the manual garbage collection step from the pytest_runtest_call
hook
+in conftest.py
. In some cases (e.g. the positioner widgets) this is an ill-timed
+redundant call.
If an external package’s widgets (and none of ours) are showing up in the
+widget cleanup check (also in the pytest_runtest_call
hook), try using
+the @pytest.mark.no_cleanup_check
decorator. If these come from typhos
+it’s fairly important to fix the issue, but if they come from an external
+package it’s hard to do something about it.
There are a few major differences between local and cloud builds, even +on the same architecture:
+Cloud builds set the environment variable for offscreen rendering (no rendering).
+This slightly changes the timing and drastically changes the implementation of
+the qt drawing primitives. You can set this yourself locally via
+export QT_QPA_PLUGIN=offscreen
.
Cloud builds use the latest versions of packages, which may differ from the ones +you have installed locally.
Ideally, the test suite should pass both on local hardware with the default +qpa plugin and also on the cloud.
Update the title above with your issue number and a 1-2 word title. +Your filename should be issuenumber-title.rst, substituting appropriately.
+Make sure to fill out any section that represents changes you have made, +or replace the default bullet point with N/A.
+List backwards-incompatible changes here. +Changes to PVs don’t count as API changes for this library, +but changing method and component names or changing default behavior does.
List new updates that add utility to many classes, +provide a new base classes, add options to helper methods, etc.
List bug fixes that are not covered in the above sections.
List anything else. The intent is to accumulate changes +that the average user does not need to worry about.
List your github username and anyone else who made significant +code or conceptual contributions to the PR. You don’t need to +add reviewers unless their suggestions lead to large rewrites. +These will be used in the release notes to give credit and to +notify you when your code is being tagged.
Utility functions for typhos
+Monitor connection status in a background thread
+device (ophyd.Device) – The device to grab signals from
include_lazy (bool, optional) – Include lazy signals as well
Connection update signal with signature:
+(signal, connected, metadata_dict)
+
QtCore.Signal
+A QLineEdit event filter for editing vs not editing style handling. +This will make the QLineEdit look like a QLabel when the user is +not editing it.
+ + + + + + +Alias for field number 0
+Alias for field number 1
+Alias for field number 2
+Monitor connection status in a background thread
+Connection update signal with signature:
+(signal, connected, metadata_dict)
+
QtCore.Signal
+Worker thread helper
+func (callable) – The function to call during run()
*args – Arguments for the function call
**kwargs – Keyword rarguments for the function call
Base widget for all Typhos widgets that interface with devices
+A QLabel with an animation for loading status.
+The timeout value in milliseconds for when to stop the animation +and replace it with a default timeout message.
+A PyQt-compatible slot for a partial method.
+This utility handles deleting the connection when the method class instance +gets garbage collected. This avoids cycles in the garbage collector +that would prevent the instance from being garbage collected prior to the +program exiting.
+signal_owner (QtCore.QObject) – The owner of the signal.
signal (QtCore.Signal) – The signal instance itself.
method (instance method) – The method slot to call when the signal fires.
*args – Arguments to pass to the method.
**kwargs – Keyword arguments to pass to the method.
Apply all the stylesheets at once, along with the Fusion style.
+Applies stylesheets with the following priority order: +- Any existing stylesheet data on the widget +- User stylesheets in the paths argument +- User stylesheets in PYDM_STYLESHEET (which behaves as a path) +- Typhos’s stylesheet (either the dark or the light variant) +- PyDM’s built-in stylesheet, if PYDM_STYLESHEET_INCLUDE_DEFAULT is set.
+The Fusion style can only be applied to a QApplication.
+dark (bool, optional) – Whether or not to use the QDarkStyleSheet theme. By default the light +theme is chosen.
paths (iterable of str, optional) – User-provided paths to stylesheets to apply.
include_pydm (bool, optional) – Whether or not to use the stylesheets defined in the pydm environment +variables. Defaults to True.
widget (QWidget, optional) – The widget to apply the stylesheet to. +If omitted, apply to the whole QApplication.
Create a PyDM address from arbitrary signal type
+Create a valid PyDM channel from a PV name
+Create a nicer, human readable alias from a Python attribute name
+Create a human readable name for a device
+device (ophyd.Device)
strip_parent (bool or Device) – Remove the parent name of the device from name. If strip_parent is +True, the name of the direct parent of the device is stripped. If a +device is provided the name of that device is used. This allows +specification for removal at any point of the device schema
Generate code required to load device
in another process
Return code to create a device from its repr
information.
device (ophyd.Device)
+Combine multiple qss stylesheets into one qss stylesheet.
+If two stylesheets make conflicting specifications, the one passed into +this function first will take priority.
+This is accomplished by placing the text from the highest-priority +stylesheets at the bottom of the combined stylesheet. Stylesheets are +evaluated in order from top to bottom, and newer elements on the bottom +will override elements at the top.
+stylesheets (iterable of str or pathlib.Path) – An itetable, such as a list, of the stylesheets to combine. +Each element can either be a fully-loaded stylesheet or a full path to +a stylesheet. Stylesheet paths must end in the .qss suffix. +In the unlikely event that a string is both a valid path +and a valid stylesheet, it will be interpretted as a path, +even if no file exists at that path.
+composed_style – A string suitable for passing into QWidget.setStylesheet that +incorporates all of the input stylesheets.
+[Context manager] Monitor connection status from a number of signals
+Filters out any other metadata updates, only calling once +connected/disconnected
+*signals (ophyd.OphydObj) – Signals to monitor
callback (callable) – Callback to run, with same signature as that of
+ophyd.OphydObj.subscribe()
. obj
and connected
are
+guaranteed kwargs.
Dump the layout of a QtWidgets.QGridLayout
to file
.
Search for filename filename
in the list of paths paths
filename (str or pathlib.Path) – The filename
paths (list or iterable, optional) – List of paths to search. Defaults to DISPLAY_PATHS.
All filenames that match in the given paths
+Finds the first parent of a widget that is an instance of klass
widget (QWidget) – The widget from which to start the search
cls (type, optional) – The class which the parent must be an instance of
Finds the root ancestor of a widget.
+widget (QWidget) – The widget from which to start the search
+Given a class cls and a view type (such as ‘detailed’), search paths +for potential templates to show.
+cls (class) – Search for templates with this class name
view_type ({'detailed', 'engineering', 'embedded'}) – The view type
paths (iterable) – Iterable of paths to be expanded, de-duplicated, and searched
extensions (str or list, optional) – The template filename extension (default is '.ui'
or '.py'
)
include_mro (bool, optional) – Include superclasses - those in the MRO - of cls
as well
path (pathlib.Path) – A matching path, ordered from most-to-least specific.
+Get all signals in a given device
+device (ophyd.Device) – ophyd Device to monitor
include_lazy (bool, optional) – Include lazy signals as well
filter_by (callable, optional) – Filter signals, with signature callable(ophyd.Device.ComponentWalk)
Get the component that made the given object.
+obj (ophyd.OphydItem) – The ophyd item for which to get the component.
+component – The component, if available.
+ophyd.Component
+Return the non-fake class, given a fake class
+That is:
+fake_cls = ophyd.sim.make_fake_device(cls)
+get_device_from_fake_class(fake_cls) # -> cls
+
cls (type) – The fake class
+Get “variety” metadata from a component or signal.
+cpt (ophyd.Component or ophyd.OphydItem) – The component / ophyd item to get the metadata for.
+metadata – The metadata, if set. Otherwise an empty dictionary. This metadata is +guaranteed to be valid according to the known schemas.
+Is cls
a fake device from ophyd.sim.make_fake_device()
?
Return whether the signal is read-only, based on its class.
+In the future this may be easier to do through improvements to +introspection in the ophyd library. Until that day we need to check classes
+Is the template a core one provided with typhos?
+template (str or pathlib.Path)
+Registers the signal with PyDM, and sets the widget channel.
+signal (ophyd.OphydObj) – The signal to use.
widget (QtWidgets.QWidget) – The widget with which to connect the signal.
Decorator which connects a device signal with a widget.
+Retrieves the signal from the device, registers it with PyDM, and sets the +widget channel.
+property_attr (str) – This is one level of indirection, allowing for the component attribute
+to be configurable by way of designable properties.
+In short, this looks like:
+getattr(self.device, getattr(self, property_attr))
+The component attribute name may include multiple levels (e.g.,
+'cpt1.cpt2.low_limit'
).
widget_attr (str) – The attribute name of the widget, referenced from self
.
+The component attribute name may include multiple levels (e.g.,
+'ui.low_limit'
).
hide_unavailable (bool) – Whether or not to hide widgets for which the device signal is not +available
” +Load a file saved via Typhos
+suite
+Load a .ui file, perform macro substitution, then return the resulting QWidget.
+ +Make a Python string into a valid Python identifier
+Context manager which disables the ophyd.device.Device +lazy_wait_for_connection behavior and later restore its value.
+Patches QtCore.QMetaObject.connectSlotsByName to catch SystemErrors.
+Create an inheritable base class from a Python Enum, which can also be used +for Q_ENUMS.
+Bring a widget’s window into focus and on top of the window stack.
+If the window is minimized, unminimize it.
+Different window managers respond differently to the various +methods called here, the chosen sequence was intended for +good behavior on as many systems as possible.
+Reload the stylesheet of the provided widget
+Return a de-duplicated list/tuple of items in list_, retaining order
+Create a file capable of relaunching the TyphosSuite
+suite (TyphosSuite)
file_or_buffer (str or file-like) – Either a path to the file or a handle that supports write
[Context manager] Subscribe to a specific event from all objects
+Unsubscribes all signals before exiting
+*objects (ophyd.OphydObj) – Ophyd objects (signals) to monitor
callback (callable) – Callback to run, with same signature as that of
+ophyd.OphydObj.subscribe()
.
event_type (str, optional) – The event type to subscribe to
run (bool, optional) – Run the previously cached subscription immediately
[Context manager] Subscribe to event_type
from signals in device
Unsubscribes all signals before exiting
+device (ophyd.Device) – ophyd Device to monitor
callback (callable) – Callback to run, with same signature as that of
+ophyd.OphydObj.subscribe()
event_type (str, optional) – The event type to subscribe to
run (bool, optional) – Run the previously cached subscription immediately
include_lazy (bool, optional) – Include lazy signals as well
filter_by (callable, optional) – Filter signals, with signature callable(ophyd.Device.ComponentWalk)
Yield screenshots of all top-level widgets.
+visible_only (bool, optional) – Only take screenshots of visible widgets.
+widget (QtWidgets.QWidget) – The widget relating to the screenshot.
screenshot (QtGui.QImage) – The screenshot image.
Take a screenshot of the given widget, returning a QImage.
+Use the Typhos stylesheet
+This is no longer used directly in typhos in favor of +apply_standard_stylesheets.
+This can still be used if you want the legacy behavior of ignoring PyDM +environment variables. The function is unchanged.
+dark (bool, optional) – Whether or not to use the QDarkStyleSheet theme. By default the light +theme is chosen.
+Get the _GlobalDisplayPathCache singleton.
+A cache for all configured display paths.
+Environment variable PYDM_DISPLAYS_PATH
.
Typhos package built-in paths.
Add a path to be searched during glob
.
path (pathlib.Path or str) – The path to add.
+Cache of ophyd object descriptions.
+obj.describe()
is called in a thread from the global QThreadPool, and
+new results are marked by the Signal new_description
.
To access a description, call get()
. If available, it will be
+returned immediately. Otherwise, wait for the new_description
Signal.
The thread which monitors connection status.
+ObjectConnectionMonitorThread
To access a description, call this method. If available, it will be
+returned immediately. Otherwise, upon connection and successful
+describe()
call, the new_description
Signal will be emitted.
obj (ophyd.OphydObj
) – The object to get the description of.
desc – If available in the cache, the description will be returned.
+dict or None
+Get the _GlobalWidgetTypeCache singleton.
+Cache of ophyd object Typhos widget types.
+obj.describe()
is called using _GlobalDescribeCache
and are
+therefore threaded and run in the background. New results are marked by
+the Signal widgets_determined
.
To access a set of widget types, call get()
. If available, it will
+be returned immediately. Otherwise, wait for the widgets_determined
+Signal.
The describe cache, used for determining widget types.
+The cache holding widget type information.
+Keyed on obj
, the values are SignalWidgetInfo
tuples.
To access widget types, call this method. If available, it will be
+returned immediately. Otherwise, upon connection and successful
+describe()
call, the widgets_determined
Signal will be emitted.
obj (ophyd.OphydObj
) – The object to get the widget types.
desc – If available in the cache, the information will be returned.
+SignalWidgetInfo
or None
Typhos uses a few custom widgets to create a clean and concise user interface. +While most users should not be interacting with these directly, there may be a +need if a user opts to create their display by hand instead of automatically +generating one.
+If you would just like a widget for an ophyd.Signal
, there
+is a function available:
Factory for creating a PyDMWidget from a signal
+widget – PyDMLabel, PyDMLineEdit, or PyDMEnumComboBox based on whether we should
+be able to write back to the widget and if the signal has enum_strs
PyDMWidget
+Provides information on how to create signal widgets: class and kwargs.
+Determine which widget class should be used for the given signal
+widget_class (class) – The class to use for the widget
kwargs (dict) – Keyword arguments for the class
Determine which widget class should be used for the given signal.
+signal (ophyd.Signal) – Signal object to determine widget class
read_only (bool, optional) – Should the chosen widget class be read-only?
widget_class (class) – The class to use for the widget
kwargs (dict) – Keyword arguments for the class
One of the major design principles of Typhos is that users should be able to +see what they need and hide one they don’t. Thefore, many of the widget +implementations are placed in “Panels” these consist of QPushButton header that +hides and shows the contents. Each variation in Typhos is documented below.
+Basic panel layout for ophyd.Signal
and other ophyd objects.
This panel does not support hierarchical display of signals; rather, it +flattens a device hierarchy showing all signals in the same area.
+signals (OrderedDict, optional) – Signals to include in the panel. +Parent of panel.
+A signal indicating that loading of the panel has completed.
+QtCore.Signal
+See also
+ +Add widgets
to the next row.
If fewer than NUM_COLS
widgets are given, the last widget will be
+adjusted automatically to span the remaining columns.
*widgets – List of QtWidgets.QWidget
.
row – The row number.
+Add a signal to the panel.
+The type of widget control that is drawn is dependent on
+_read_pv
, and _write_pv
. attributes.
If widget information for the given signal is available in the global +cache, the widgets will be created immediately. Otherwise, a row will +be reserved and widgets created upon signal connection and background +description callback.
+signal (EpicsSignal, EpicsSignalRO) – Signal to create a widget.
name (str, optional) – The name to be used for the row label. This defaults to
+signal.name
.
row – Row number that the signal information was added to in the +SignalPanel.layout()`.
+Filter signals based on the given kinds.
+kinds (list of ophyd.Kind
) – List of kinds to show.
name_filter (str, optional) – Name filter text - show only signals that match this string. This +is applied after the “omit_names” and “show_names” filters.
show_names (list of str, optinoal) – Names to explicitly show. Applied before the omit filter.
omit_names (list of str, optinoal) – Names to explicitly omit.
Get label text for a given attribute.
+For a basic signal panel, use the full dotted name. This is because +this panel flattens the device hierarchy, and using only the last +attribute name may lead to ambiguity or name clashes.
+Get the number of filled-in rows.
+Get all instantiated signals, omitting components.
+signals – With the form: {signal_name: signal}
.
Get all signals visible according to filters, omitting components.
+signals – With the form: {signal_name: signal}
.
Panel of Signals for a given device, using SignalPanel
.
parent (QtWidgets.QWidget, optional) – The parent widget.
init_channel (str, optional) – The PyDM channel with which to initialize the widget.
Options for sorting signals.
+This can be used as a base class for subclasses of
+QtWidgets.QWidget
, allowing this to be used in
+QtCore.Property
and therefore in the Qt designer.
Get the filter settings dictionary.
+Generate a context menu for this TyphosSignalPanel.
+Get or set the current name filter.
+Get or set the list of names to omit.
+Open a context menu when the Default Context Menu is requested.
+ev (QEvent)
+Fix the parent container’s size whenever our size changes.
+This also runs when we add or filter rows.
+Fix the parent container’s size whenever we switch visibility.
+This also runs when we toggle a row visibility using the title +and when all signal rows get filtered all at once.
+Typhos hook for when the TyphosDeviceDisplay is associated.
+Show ophyd.Kind.config signals
+Show ophyd.Kind.hinted signals
+Get or set the list of names to omit.
+Show ophyd.Kind.normal signals
+Show ophyd.Kind.omitted signals
+Get or set the order that the signals will be placed in layout.
+Composite panel layout for ophyd.Signal
and other ophyd objects.
Contrasted to SignalPanel
, this class retains the hierarchy built
+into an ophyd.Device
hierarchy. Individual signals mix in with
+sub-device displays, which may or may not have custom screens.
A signal indicating that loading of the panel has completed.
+QtCore.Signal
+Add a sub-device to the next row.
+device (ophyd.Device) – The device to add.
name (str) – The name/label to go with the device.
Return all visible signals and components.
+Hierarchical panel for a device, using CompositeSignalPanel
.
parent (QtWidgets.QWidget, optional) – The parent widget.
init_channel (str, optional) – The PyDM channel with which to initialize the widget.
Widget to interact with a ophyd.Positioner
.
Standard positioner motion requires a large amount of context for
+operators. For most motors, it may not be enough to simply have a text
+field where setpoints can be punched in. Instead, information like soft
+limits and hardware limit switches are crucial for a full understanding of
+the position and behavior of a motor. The widget will work with any object
+that implements the method set
, however to get other relevant
+information, we see if we can find other useful signals. Below is a table
+of attributes that the widget looks for to inform screen design.
Widget |
+Attribute Selection |
+
---|---|
User Readback |
+The |
+
User Setpoint |
+The |
+
Limit Switches |
+The |
+
Soft Limits |
+The |
+
Set and Tweak |
+Both of these methods simply use |
+
Stop |
+
|
+
Move Indicator |
+The |
+
Error Message |
+The |
+
Clear Error |
+
|
+
Alarm Circle |
+Uses the |
+
Options for TyphosAlarm.kindLevel.
+The attribute name for the acceleration time signal.
+Clear the error messages from the device and screen.
+The device may have errors in the IOC. These will be cleared by calling +the clear_error method.
+The screen may have errors from the status of the last move. These will +be cleared from view.
+The associated device.
+The attribute name for the IOC error message label.
+The last requested move failed
+The attribute name for the high limit switch signal.
+The attribute name for the high (soft) limit travel signal.
+The attribute name for the low limit switch signal.
+The attribute name for the low limit signal.
+Current state of widget
+This will lag behind the actual state of the positioner in order to +prevent unnecessary rapid movements
+The attribute name for the motor moving indicator.
+The attribute name for the readback signal.
+The attribute name for the setpoint signal.
+If True, show the expert button.
+The expert button opens a full suite for the device. +You typically want this False when you’re already inside the +suite that the button would open. +You typically want this True when you’re using the positioner widget +inside of an unrelated screen. +This will default to False.
+The last requested move was successful
+The attribute name for the velocity signal.
+Function Panel.
+Similar to SignalPanel
but instead displays a set of function
+widgets arranged in a row. Each provided method has a
+FunctionDisplay
generated for it an added to the layout.
methods (list of callables, optional) – List of callables to add to the FunctionPanel.
parent (QWidget)
Add a FunctionDisplay
.
func (callable) – Annotated callable function.
args – All additional parameters are passed directly to the
+FunctionDisplay
constructor.
kwargs – All additional parameters are passed directly to the
+FunctionDisplay
constructor.
QPushButton to access a method of a Device.
+The function provided by the loaded device and the method_name
+will be run when the button is clicked. If use_status
is set to True,
+the button will be disabled while the Status
object is active.
Add a new device to the widget.
+device (ophyd.Device)
+Create a TyphosMethodButton from a device.
+Name of method on provided Device to execute.
+Use the status to enable and disable the button.
+A bit indicator that emits clicked when clicked.
+ + +QPushButton to show a 2-d array.
+Notes
+Used for variety array-image (readback)
alias of QMainWindow
QPushButton to launch a QDialog with a PyDMWidget
+alias of QWidget
QDockWidget modified to emit a signal when closed
+ + +A table widget which reshapes and displays a given waveform value.
+Notes
+Used for variety array-tabular (setpoint)
Callback invoked when the Channel value is changed.
+new_waveform (np.ndarray) – The new waveform value from the channel.
+Additional component variety metadata.
+Displays an integer value as individual, read-only bit indicators.
+Notes
+Used for variety bitmask (readback)
Additional component variety metadata.
+Displays an integer value as individual, toggleable bit indicators.
+Notes
+Used for variety bitmask (setpoint)
Number of bits to interpret.
+Re-implemented from PyDM to support changing of bit indicators.
+Notes
+Used for variety text-enum (setpoint)
Used for variety enum (setpoint)
A pushbutton widget which executes a command by sending a specific value.
+See also
+ +Notes
+Used for variety command-setpoint-tracks-readback (setpoint)
Used for variety command-proc (setpoint)
Used for variety command (setpoint)
Callback invoked when the Channel has new enum values. +This callback also triggers a value_changed call so the +new enum values to be broadcasted
+new_enum_strings (tuple) – The new list of values
+Additional component variety metadata.
+A group of buttons which represent several command options.
+These options can come from directly from the control layer or can be +overridden with variety metadata.
+See also
+ +Notes
+Used for variety command-enum (setpoint)
Callback invoked when the Channel has new enum values. +This callback also triggers a value_changed call so the +new enum values to be broadcasted.
+new_enum_strings (tuple) – The new list of values
+Additional component variety metadata.
+Reimplementation of PyDMLabel to set some custom defaults
+Notes
+Used for variety array-nd (setpoint)
Used for variety text-multiline (readback)
Used for variety text-enum (readback)
Used for variety text (readback)
Used for variety scalar-tweakable (readback)
Used for variety scalar-range (readback)
Used for variety scalar (readback)
Used for variety enum (readback)
Used for variety command-setpoint-tracks-readback (readback)
Used for variety command-enum (readback)
Used for variety array-nd (readback)
Dynamically adjust the font size
+Reimplementation of PyDMLineEdit to set some custom defaults
+Notes
+Used for variety text (setpoint)
Used for variety scalar (setpoint)
Number of items to show in the context menu “setpoint history”
+timestamp}
+History of setpoints, as a dictionary of {setpoint
+Callback invoked when the Channel has new unit value.
+This callback also triggers an update_format_string call so the
+new unit value is considered if `showUnits`
is set.
new_unit (str) – The new unit
+Fetch the Widget specific context menu which will be populated with additional tools by assemble_tools_menu.
+If the return of this method is None a new QMenu will be created by assemble_tools_menu.
+QMenu or None
+A slider widget which displays a scalar value with an explicit range.
+Notes
+Used for variety scalar-range (setpoint)
Callback invoked when the connection state of the Channel is changed. +This callback acts on the connection state to enable/disable the widget +and also trigger the change on alarm severity to ALARM_DISCONNECTED.
+connected (int) – When this value is 0 the channel is disconnected, 1 otherwise.
+Delta signal, used as the source for “delta_value”.
+Delta value, an alternative to “num_points” provided by PyDMSlider.
+num_points is calculated using the current min/max and the delta value, +if set.
+Additional component variety metadata.
+Class to display a Device or Tool in the sidebar
+Notes
+ + + + + + + + +Widget for a tweakable scalar.
+parent (QWidget) – The parent widget.
init_channel (str, optional) – The channel to be used by the widget.
Notes
+Used for variety scalar-tweakable (setpoint)
Additional component variety metadata.
+A writable, multiline text editor with support for PyDM Channels.
+parent (QWidget) – The parent widget.
init_channel (str, optional) – The channel to be used by the widget.
(setpoint) (* Used for variety text-multiline)
Additional component variety metadata.
+