source: bcdaqwidgets/trunk/src/bcdaqwidgets/bcdaqwidgets.py @ 1451

Last change on this file since 1451 was 1451, checked in by jemian, 9 years ago

handle color of alarm severity for INVALID

  • Property svn:eol-style set to native
  • Property svn:executable set to *
  • Property svn:keywords set to Date Revision Author HeadURL Id
File size: 21.0 KB
Line 
1#!/usr/bin/env python
2
3########### SVN repository information ###################
4# $Date: 2014-01-17 00:35:51 +0000 (Fri, 17 Jan 2014) $
5# $Author: jemian $
6# $Revision: 1451 $
7# $URL: bcdaqwidgets/trunk/src/bcdaqwidgets/bcdaqwidgets.py $
8# $Id: bcdaqwidgets.py 1451 2014-01-17 00:35:51Z jemian $
9########### SVN repository information ###################
10
11'''
12PySide-based EPICS-aware widgets for Python
13
14Copyright (c) 2009 - 2013, UChicago Argonne, LLC.
15See LICENSE file for details.
16
17The bcdaqwidgets [#]_ module provides a set of PySide (also Qt4)
18widgets that are EPICS-aware.  These include:
19
20=============================  ================================================================
21widget                         description
22=============================  ================================================================
23:func:`BcdaQLabel`             EPICS-aware QLabel widget
24:func:`BcdaQLineEdit`          EPICS-aware QLineEdit widget
25:func:`BcdaQPushButton`        EPICS-aware QPushButton widget
26:func:`BcdaQMomentaryButton`   sends a value when pressed or released, label does not change
27:func:`BcdaQToggleButton`      toggles boolean PV when pressed
28=============================  ================================================================
29
30.. [#] BCDA: Beam line Controls and Data Acquisition group
31       of the Advanced Photon Source, Argonne National Laboratory,
32       http://www.aps.anl.gov/bcda
33
34'''
35
36
37import epics
38try:
39    from PyQt4 import QtCore, QtGui
40    pyqtSignal = QtCore.pyqtSignal
41except:
42    from PySide import QtCore, QtGui
43    pyqtSignal = QtCore.Signal
44
45
46def typesafe_enum(*sequential, **named):
47    '''
48    typesafe  enum
49   
50    EXAMPLE::
51
52        >>> Numbers = typesafe_enum('ZERO', 'ONE', 'TWO', four='IV')
53        >>> Numbers.ZERO
54        0
55        >>> Numbers.ONE
56        1
57        >>> Numbers.four
58        IV
59   
60    :see: http://stackoverflow.com/questions/36932/whats-the-best-way-to-implement-an-typesafe_enum-in-python
61    '''
62    enums = dict(zip(sequential, range(len(sequential))), **named)
63    return type('TypesafeEnum', (), enums)
64
65AllowedStates = typesafe_enum('DISCONNECTED', 'CONNECTED',)
66CLUT = {   # clut: Color LookUp Table
67    AllowedStates.DISCONNECTED: "#ffffff",      # white
68    AllowedStates.CONNECTED:    "#e0e0e0",      # a bit darker than default #f0f0f0
69   
70}
71
72SeverityColor = typesafe_enum('NO_ALARM', 'MINOR', 'MAJOR', 'CALC_INVALID')
73SeverityColor.NO_ALARM =     "green"        # green
74SeverityColor.MINOR =        "#ff0000"      # dark orange since yellow looks bad against gray
75SeverityColor.MAJOR =        "red"          # red
76SeverityColor.CALC_INVALID = "pink"         # pink
77
78
79class StyleSheet(object):
80    '''
81    manage style sheet settings for a Qt widget
82   
83    Example::
84
85        widget = QtGui.QLabel('example label')
86        sty = bcdaqwidgets.StyleSheet(widget)
87        sty.updateStyleSheet({
88            'font': 'bold',
89            'color': 'white',
90            'background-color': 'dodgerblue',
91            'qproperty-alignment': 'AlignCenter',
92        })
93   
94    '''
95
96    def __init__(self, widget, sty={}):
97        '''
98        :param obj widget: the Qt widget on which to apply the style sheet
99        :param dict sty: starting dictionary of style sheet settings
100        '''
101        self.widget = widget
102        widgetclass = str(type(widget)).strip('>').split('.')[-1].strip("'")
103        self.widgetclass = widgetclass
104        self.style_cache = dict(sty)
105
106    def clearCache(self):
107        '''clear the internal cache'''
108        self.style_cache = {}
109
110    def updateStyleSheet(self, sty={}):
111        '''change specified styles and apply all to widget'''
112        self._updateCache(sty)
113        self.widget.setStyleSheet(str(self))
114
115    def _updateCache(self, sty={}):
116        '''update internal cache with specified styles'''
117        for key, value in sty.items():
118            self.style_cache[key] = value
119
120    def __str__(self):
121        '''returns a CSS text with the cache settings'''
122        s = self.widgetclass + ' {\n'
123        for key, value in sorted(self.style_cache.items()):
124            s += '    %s: %s;\n' % (key, value)
125        s += '}'
126        return s
127
128
129class BcdaQSignalDef(QtCore.QObject):
130    '''
131    Define the signals used to communicate between the PyEpics
132    thread and the PySide (main Qt4 GUI) thread.
133    '''
134    # see: http://www.pyside.org/docs/pyside/PySide/QtCore/Signal.html
135    # see: http://zetcode.com/gui/pysidetutorial/eventsandsignals/
136
137    newFgColor = pyqtSignal()
138    newBgColor = pyqtSignal()
139    newText    = pyqtSignal()
140
141
142class BcdaQWidgetSuper(object):
143    '''superclass for EPICS-aware widgets'''
144
145    def __init__(self, pvname=None, useAlarmState=False):
146        self.style_dict = {}
147        self.text_cache = ' ' * 4
148        self.pv = None                           # PyEpics PV object
149        self.ca_callback = None
150        self.ca_connect_callback = None
151        self.state = AllowedStates.DISCONNECTED
152        self.labelSignal = BcdaQSignalDef()
153        self.clut = dict(CLUT)
154
155        self.useAlarmState = useAlarmState
156        self.severity_color_list = [SeverityColor.NO_ALARM, SeverityColor.MINOR, SeverityColor.MAJOR, SeverityColor.CALC_INVALID]
157
158        # for internal use persisting the various styleSheet settings
159        self._style_sheet = StyleSheet(self)
160
161    def ca_connect(self, pvname, ca_callback=None, ca_connect_callback=None):
162        '''
163        Connect this widget with the EPICS pvname
164       
165        :param str pvname: EPICS Process Variable name
166        :param obj ca_callback: EPICS CA callback handler method
167        :param obj ca_connect_callback: EPICS CA connection state callback handler method
168        '''
169        if self.pv is not None:
170            self.ca_disconnect()
171        if len(pvname) > 0:
172            self.ca_callback = ca_callback
173            self.ca_connect_callback = ca_connect_callback
174            self.pv = epics.PV(pvname, 
175                               callback=self.onPVChange,
176                               connection_callback=self.onPVConnect)
177            self.state = AllowedStates.CONNECTED
178            self.setToolTip(pvname)
179
180    def ca_disconnect(self):
181        '''disconnect from this EPICS PV, if connected'''
182        if self.pv is not None:
183            self.pv.remove_callback()
184            pvname = self.pv.pvname
185            self.pv.disconnect()
186            self.pv = None
187            self.ca_callback = None
188            self.ca_connect_callback = None
189            self.state = AllowedStates.DISCONNECTED
190            self.text_cache = ' ' * 4
191            self.SetText()
192            self.SetBackgroundColor()
193            self.setToolTip(pvname + ' not connected')
194
195    def onPVConnect(self, pvname='', **kw):
196        '''respond to a PyEpics CA connection event'''
197        conn = kw['conn']
198        self.text_cache = {      # adjust the text
199                          False: '',    #'disconnected',
200                          True:  'connected',
201                      }[conn]
202        self.labelSignal.newText.emit()      # threadsafe update of the widget
203        self.state = {      # adjust the state
204                          False: AllowedStates.DISCONNECTED,
205                          True:  AllowedStates.CONNECTED,
206                      }[conn]
207        self.labelSignal.newBgColor.emit()   # threadsafe update of the widget
208        if self.ca_connect_callback is not None:
209            # caller wants to be notified of this camonitor event
210            self.ca_connect_callback(**kw)
211
212    def onPVChange(self, pvname=None, char_value=None, **kw):
213        '''respond to a PyEpics camonitor() event'''
214        self.text_cache = char_value         # cache the new text locally
215        self.labelSignal.newText.emit()      # threadsafe update of the widget
216        if self.ca_callback is not None:
217            # caller wants to be notified of this camonitor event
218            self.ca_callback(pvname=pvname, char_value=char_value, **kw)
219
220    def SetText(self, *args, **kw):
221        '''set the text of the widget (threadsafe update)'''
222        # pull the new text from the cache (set by onPVChange() method)
223        self.setText(self.text_cache)
224
225        # if desired, color the text based on the alarm severity
226        if self.useAlarmState and self.pv is not None:
227            self.pv.get_ctrlvars()
228            if self.pv.severity is not None:
229                if self.pv.severity < 0 or self.pv.severity >= len(self.severity_color_list):
230                    print self.pv.severity
231                    print self.severity_color_list
232                    pass
233                color = self.severity_color_list[self.pv.severity]
234                self.updateStyleSheet({'color': color})
235
236    def updateStyleSheet(self, changes_dict):
237        '''update the widget's stylesheet'''
238        self._style_sheet.updateStyleSheet(changes_dict)
239
240
241class BcdaQLabel(QtGui.QLabel, BcdaQWidgetSuper):
242    '''
243    Provide the value of an EPICS PV on a PySide.QtGui.QLabel
244   
245    USAGE::
246   
247        from moxy.qtlib import bcdaqwidgets
248       
249        ...
250   
251        widget = bcdaqwidgets.BcdaQLabel()
252        widget.ca_connect("example:m1.RBV")
253       
254    :param str pvname: epics process variable name for this widget
255    :param bool useAlarmState: change the text color based on pv severity
256    :param str bgColorPv: update widget's background color based on this pv's value
257   
258    '''
259
260    def __init__(self, pvname=None, useAlarmState=False, bgColorPv=None):
261        ''':param str text: initial Label text (really, we can ignore this)'''
262        BcdaQWidgetSuper.__init__(self, useAlarmState=useAlarmState)
263        QtGui.QLabel.__init__(self, self.text_cache)
264
265        # define the signals we'll use in the camonitor handler to update the GUI
266        self.labelSignal = BcdaQSignalDef()
267        self.labelSignal.newBgColor.connect(self.SetBackgroundColor)
268        self.labelSignal.newText.connect(self.SetText)
269
270        self.updateStyleSheet({
271                               'background-color': 'bisque', 
272                               'border': '1px solid gray', 
273                               'font': 'bold', 
274                               })
275
276        self.clut = dict(CLUT)
277        self.pv = None
278        self.ca_callback = None
279        self.ca_connect_callback = None
280        self.state = AllowedStates.DISCONNECTED
281        self.SetBackgroundColor()
282        self.setAlignment(QtCore.Qt.AlignHCenter)
283
284        if pvname is not None and isinstance(pvname, str):
285            self.ca_connect(pvname)
286
287        if bgColorPv is not None:
288            self.bgColorObj = epics.PV(pvname=bgColorPv, callback=self.onBgColorObjChanged)
289
290        self.bgColor_clut = {'not connected': 'white', '0': '#88ff88', '1': 'transparent'}
291        self.bgColor = None
292
293        self.bgColorSignal = BcdaQSignalDef()
294        self.bgColorSignal.newBgColor.connect(self.SetBackgroundColorExtra)
295
296    def onBgColorObjChanged(self, *args, **kw):
297        '''epics pv callback when bgColor PV changes'''         
298        if not self.bgColorObj.connected:     # white and displayed text is ' '
299            self.bgColor = self.bgColor_clut['not connected']
300        else:
301            value = str(self.bgColorObj.get())
302            if value in self.bgColor_clut:
303                self.bgColor = self.bgColor_clut[value]
304        # trigger the background color to change
305        self.bgColorSignal.newBgColor.emit()
306
307    def SetBackgroundColorExtra(self, *args, **kw):
308        '''changes the background color of the widget'''
309        if self.bgColor is not None:
310            self.updateStyleSheet({'background-color': self.bgColor})
311            self.bgColor = None
312
313    def SetBackgroundColor(self, *args, **kw):
314        '''set the background color of the widget via its stylesheet'''
315        color = self.clut[self.state]
316        self.updateStyleSheet({'background-color': color})
317
318
319class BcdaQLineEdit(QtGui.QLineEdit, BcdaQWidgetSuper):
320    '''
321    Provide the value of an EPICS PV on a PySide.QtGui.QLineEdit
322   
323    USAGE::
324   
325        from moxy.qtlib import bcdaqwidgets
326       
327        ...
328   
329        widget = bcdaqwidgets.BcdaQLineEdit()
330        widget.ca_connect("example:m1.VAL")
331
332    '''
333
334    def __init__(self, pvname=None, useAlarmState=False):
335        ''':param str text: initial Label text (really, we can ignore this)'''
336        BcdaQWidgetSuper.__init__(self)
337        QtGui.QLineEdit.__init__(self, self.text_cache)
338
339        # define the signals we'll use in the camonitor handler to update the GUI
340        self.labelSignal.newBgColor.connect(self.SetBackgroundColor)
341        self.labelSignal.newText.connect(self.SetText)
342
343        self.clut = dict(CLUT)
344        self.clut[AllowedStates.CONNECTED] = "bisque"
345        self.updateStyleSheet({
346                               'background-color': 'bisque', 
347                               'border': '3px inset gray', 
348                               })
349
350        self.SetBackgroundColor()
351        self.setAlignment(QtCore.Qt.AlignHCenter)
352        self.returnPressed.connect(self.onReturnPressed)
353
354        if pvname is not None and isinstance(pvname, str):
355            self.ca_connect(pvname)
356
357    def onReturnPressed(self):
358        '''send the widget's text to the EPICS PV'''
359        if self.pv is not None and len(self.text()) > 0:
360            self.pv.put(self.text())
361
362    def SetBackgroundColor(self, *args, **kw):
363        '''set the background color of the QLineEdit() via its QPalette'''
364        color = self.clut[self.state]
365        self.updateStyleSheet({'background-color': color})
366
367
368class BcdaQPushButton(QtGui.QPushButton, BcdaQWidgetSuper):
369    '''
370    Provide a QtGui.QPushButton connected to an EPICS PV
371   
372    It is necessary to also call the SetPressedValue() and/or
373    SetReleasedValue() method to define the value to be sent
374    to the EPICS PV with the corresponding push button event.
375    If left unconfigured, no action will be taken.
376   
377    USAGE::
378   
379        from moxy.qtlib import bcdaqwidgets
380       
381        ...
382   
383        widget = bcdaqwidgets.BcdaQPushButton()
384        widget.ca_connect("example:bo0")
385        widget.SetReleasedValue(1)
386   
387    '''
388
389    def __init__(self, label='', pvname=None):
390        ''':param str text: initial Label text (really, we can ignore this)'''
391        BcdaQWidgetSuper.__init__(self)
392        QtGui.QPushButton.__init__(self, self.text_cache)
393        self.setText(label)
394
395        self.labelSignal = BcdaQSignalDef()
396        self.labelSignal.newBgColor.connect(self.SetBackgroundColor)
397        self.labelSignal.newText.connect(self.SetText)
398
399        self.clut = dict(CLUT)
400        self.updateStyleSheet({'font': 'bold',})
401
402        self.pv = None
403        self.ca_callback = None
404        self.ca_connect_callback = None
405        self.state = AllowedStates.DISCONNECTED
406        self.setCheckable(True)
407        self.SetBackgroundColor()
408        self.clicked[bool].connect(self.onPressed)
409        self.released.connect(self.onReleased)
410
411        self.pressed_value = None
412        self.released_value = None
413
414        if pvname is not None and isinstance(pvname, str):
415            self.ca_connect(pvname)
416
417    def onPressed(self, **kw):
418        '''button was pressed, send preset value to EPICS'''
419        if self.pv is not None and self.pressed_value is not None:
420            self.pv.put(self.pressed_value, wait=True)
421
422    def onReleased(self, **kw):
423        '''button was released, send preset value to EPICS'''
424        if self.pv is not None and self.released_value is not None:
425            self.pv.put(self.released_value, wait=True)
426
427    def SetPressedValue(self, value):
428        '''specify the value to be sent to the EPICS PV when the button is pressed'''
429        self.pressed_value = value
430
431    def SetReleasedValue(self, value):
432        '''specify the value to be sent to the EPICS PV when the button is released'''
433        self.released_value = value
434
435    def SetBackgroundColor(self, *args, **kw):
436        '''set the background color of the QPushButton() via its stylesheet'''
437        color = self.clut[self.state]
438        self.updateStyleSheet({'background-color': color})
439
440
441class BcdaQMomentaryButton(BcdaQPushButton):
442    '''
443    Send a value when pressed or released, label does not change if PV changes.
444   
445    This is a special case of a BcdaQPushButton where the text on the button
446    does not respond to changes of the value of the attached EPICS PV.
447   
448    It is a good choice to use, for example, for a motor STOP button.
449   
450    USAGE::
451   
452        from moxy.qtlib import bcdaqwidgets
453       
454        ...
455   
456        widget = bcdaqwidgets.BcdaQMomentaryButton('Stop')
457        widget.ca_connect("example:m1.STOP")
458        widget.SetReleasedValue(1)
459
460    '''
461
462    def SetText(self, *args, **kw):
463        '''do not change the label from the EPICS PV'''
464        pass
465
466
467class BcdaQToggleButton(BcdaQPushButton):
468    '''
469    Toggles boolean PV when pressed
470   
471    This is a special case of a BcdaQPushButton where the text on the button
472    changes with the value of the attached EPICS PV.  In this case, the
473    displayed value is the name of the next state of the EPICS PV when
474    the button is pressed.
475   
476    It is a good choice to use, for example, for an ON/OFF button.
477   
478    USAGE::
479   
480        from moxy.qtlib import bcdaqwidgets
481       
482        ...
483   
484        widget = bcdaqwidgets.BcdaQToggleButton()
485        widget.ca_connect("example:room_light")
486        widget.SetReleasedValue(1)
487
488    '''
489
490    def __init__(self, pvname=None):
491        BcdaQPushButton.__init__(self)
492        self.value_names = {1: 'change to 0', 0: 'change to 1'}
493        self.setToolTip('tell EPICS PV to do this')
494
495        if pvname is not None and isinstance(pvname, str):
496            self.ca_connect(pvname)
497
498    def ca_connect(self, pvname, ca_callback=None, ca_connect_callback=None):
499        BcdaQPushButton.ca_connect(self, pvname, ca_callback=None, ca_connect_callback=None)
500        labels = self.pv.enum_strs
501        if labels is not None and len(labels)>1:
502            # describe what happens when the button is pressed
503            self.value_names[0] = labels[1]
504            self.value_names[1] = labels[0]
505
506    def onPressed(self):
507        '''button was pressed, toggle the EPICS value as a boolean'''
508        if self.pv is not None:
509            self.pv.put(not self.pv.get(), wait=True)
510
511    # disable these methods
512    def onReleased(self, **kw): pass
513    def SetPressedValue(self, value): pass
514    def SetReleasedValue(self, value): pass
515
516    def SetText(self, *args, **kw):
517        '''set the text of the widget (threadsafe update) from the EPICS PV'''
518        # pull the new text from the cache (set by onPVChange() method)
519        self.setText(self.value_names[self.pv.get()])
520
521
522#------------------------------------------------------------------
523
524
525class DemoView(QtGui.QWidget):
526    '''
527    Show the BcdaQWidgets using an EPICS PV connection.
528
529    Allow it to connect and ca_disconnect.
530    This is a variation of EPICS PV Probe.
531    '''
532
533    def __init__(self, parent=None, pvname=None, bgColorPvname=None):
534        QtGui.QWidget.__init__(self, parent)
535
536        layout = QtGui.QGridLayout()
537        layout.addWidget(QtGui.QLabel('BcdaQLabel'), 0, 0)
538        self.value = BcdaQLabel()
539        layout.addWidget(self.value, 0, 1)
540        self.setLayout(layout)
541
542        self.sig = BcdaQSignalDef()
543        self.sig.newBgColor.connect(self.SetBackgroundColor)
544        self.toggle = False
545
546        self.setWindowTitle("Demo bcdaqwidgets module")
547        if pvname is not None:
548            self.ca_connect(pvname)
549
550            layout.addWidget(QtGui.QLabel('BcdaQLabel with alarm colors'), 1, 0)
551            layout.addWidget(BcdaQLabel(pvname=pvname, useAlarmState=True), 1, 1)
552
553            pvnameBg = pvname.split('.')[0] + '.DMOV'
554            lblWidget = BcdaQLabel(pvname=pvname, bgColorPvname=pvnameBg)
555            layout.addWidget(QtGui.QLabel('BcdaQLabel with BG color change due to moving motor'), 2, 0)
556            layout.addWidget(lblWidget, 2, 1)
557
558            layout.addWidget(QtGui.QLabel('BcdaQLineEdit'), 3, 0)
559            layout.addWidget(BcdaQLineEdit(pvname=pvname), 3, 1)
560
561            pvname = pvname.split('.')[0] + '.PROC'
562            widget = BcdaQMomentaryButton(label=pvname, pvname=pvname)
563            layout.addWidget(QtGui.QLabel('BcdaQMomentaryButton'), 4, 0)
564            layout.addWidget(widget, 4, 1)
565
566    def ca_connect(self, pvname):
567        self.value.ca_connect(pvname, ca_callback=self.callback)
568
569    def callback(self, *args, **kw):
570        self.sig.newBgColor.emit()   # threadsafe update of the widget
571
572    def SetBackgroundColor(self, *args, **kw):
573        '''toggle the background color of self.value via its stylesheet'''
574        self.toggle = not self.toggle
575        color = {False: "#ccc333", True: "#cccccc",}[self.toggle]
576        self.value.updateStyleSheet({'background-color': color})
577
578
579#------------------------------------------------------------------
580
581
582def main():
583    '''command-line interface to test this GUI widget'''
584    import argparse
585    import sys
586    parser = argparse.ArgumentParser(description='Test the bcdaqwidgets module')
587
588    # positional arguments
589    # not required if GUI option is selected
590    parser.add_argument('test_PV', 
591                        action='store', 
592                        nargs='?',
593                        help="EPICS PV name", 
594                        default="prj:m1.RBV", 
595                        )
596    results = parser.parse_args()
597
598    app = QtGui.QApplication(sys.argv)
599    view = DemoView(pvname=results.test_PV)
600    view.show()
601    sys.exit(app.exec_())
602
603
604if __name__ == '__main__':
605    main()
Note: See TracBrowser for help on using the repository browser.