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

Last change on this file since 1425 was 1425, checked in by jemian, 10 years ago

try to build GUI from .ui files

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