Completed
Pull Request — master (#25)
by
unknown
30s
created

GraphUI.pushtoGraph()   A

Complexity

Conditions 2

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
c 0
b 0
f 0
dl 0
loc 6
rs 9.4285
1
#! /usr/bin/env python3
2
3
import sys
4
from datetime import datetime
5
import inspect
6
from enum import Enum
7
import logging
8
9
from PyQt5.QtCore import pyqtSignal, QTimer, Qt, QObject, QSettings, QModelIndex, QMimeData, QCoreApplication
10
from PyQt5.QtGui import QStandardItemModel, QStandardItem, QIcon
11
from PyQt5.QtWidgets import QMainWindow, QWidget, QApplication, QAbstractItemView, QMenu, QAction
12
13
from opcua import ua
14
from opcua import Node
15
16
from uaclient.uaclient import UaClient
17
from uaclient.mainwindow_ui import Ui_MainWindow
18
from uaclient.connection_dialog import ConnectionDialog
19
from uawidgets import resources
20
from uawidgets.attrs_widget import AttrsWidget
21
from uawidgets.tree_widget import TreeWidget
22
from uawidgets.refs_widget import RefsWidget
23
from uawidgets.utils import trycatchslot
24
from uawidgets.logger import QtHandler
25
26
use_graph = True
27
try:
28
    import pyqtgraph as pg
29
    import numpy as np
30
except ImportError:
31
    print("pyqtgraph or numpy are not installed, use of graph feature disabled")
32
    use_graph = False
33
34
35
36
if use_graph:
37
    pg.setConfigOptions(antialias=True)
38
    pg.setConfigOption('background', 'w')
39
    pg.setConfigOption('foreground', 'k')
40
41
logger = logging.getLogger(__name__)
42
43 View Code Duplication
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
44
class DataChangeHandler(QObject):
45
    data_change_fired = pyqtSignal(object, str, str)
46
47
    def datachange_notification(self, node, val, data):
48
        if data.monitored_item.Value.SourceTimestamp:
49
            dato = data.monitored_item.Value.SourceTimestamp.isoformat()
50
        elif data.monitored_item.Value.ServerTimestamp:
51
            dato = data.monitored_item.Value.ServerTimestamp.isoformat()
52
        else:
53
            dato = datetime.now().isoformat()
54
        self.data_change_fired.emit(node, str(val), dato)
55
56
57
class EventHandler(QObject):
58
    event_fired = pyqtSignal(object)
59
60
    def event_notification(self, event):
61
        self.event_fired.emit(event)
62
63
64
class EventUI(object):
65
66
    def __init__(self, window, uaclient):
67
        self.window = window
68
        self.uaclient = uaclient
69
        self._handler = EventHandler()
70
        self._subscribed_nodes = []  # FIXME: not really needed
71
        self.model = QStandardItemModel()
72
        self.window.ui.evView.setModel(self.model)
73
        self.window.ui.actionSubscribeEvent.triggered.connect(self._subscribe)
74
        self.window.ui.actionUnsubscribeEvents.triggered.connect(self._unsubscribe)
75
        # context menu
76
        self.window.ui.treeView.addAction(self.window.ui.actionSubscribeEvent)
77
        self.window.ui.treeView.addAction(self.window.ui.actionUnsubscribeEvents)
78
        self._handler.event_fired.connect(self._update_event_model, type=Qt.QueuedConnection)
79
80
        # accept drops
81
        self.model.canDropMimeData = self.canDropMimeData
82
        self.model.dropMimeData = self.dropMimeData
83
84
    def canDropMimeData(self, mdata, action, row, column, parent):
85
        return True
86
87
    def show_error(self, *args):
88
        self.window.show_error(*args)
89
90
    def dropMimeData(self, mdata, action, row, column, parent):
91
        node = self.uaclient.client.get_node(mdata.text())
92
        self._subscribe(node)
93
        return True
94
95
    def clear(self):
96
        self._subscribed_nodes = []
97
        self.model.clear()
98
99
    @trycatchslot
100
    def _subscribe(self, node=None):
101
        logger.info("Subscribing to %s", node)
102
        if not node:
103
            node = self.window.get_current_node()
104
            if node is None:
105
                return
106
        if node in self._subscribed_nodes:
107 View Code Duplication
            logger.info("allready subscribed to event for node: %s", node)
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
108
            return
109
        logger.info("Subscribing to events for %s", node)
110
        self.window.ui.evDockWidget.raise_()
111
        try:
112
            self.uaclient.subscribe_events(node, self._handler)
113
        except Exception as ex:
114
            self.window.show_error(ex)
115
            raise
116
        else:
117
            self._subscribed_nodes.append(node)
118
119
    @trycatchslot
120
    def _unsubscribe(self):
121
        node = self.window.get_current_node()
122
        if node is None:
123
            return
124
        self._subscribed_nodes.remove(node)
125
        self.uaclient.unsubscribe_events(node)
126
127
    @trycatchslot
128
    def _update_event_model(self, event):
129
        self.model.appendRow([QStandardItem(str(event))])
130
131
132
class DataChangeUI(object):
133
134
    def __init__(self, window, uaclient):
135
        self.window = window
136
        self.uaclient = uaclient
137
        self._subhandler = DataChangeHandler()
138
        self._subscribed_nodes = []
139
        self.model = QStandardItemModel()
140
        self.window.ui.subView.setModel(self.model)
141
        self.window.ui.subView.horizontalHeader().setSectionResizeMode(1)
142
143
        self.window.ui.actionSubscribeDataChange.triggered.connect(self._subscribe)
144
        self.window.ui.actionUnsubscribeDataChange.triggered.connect(self._unsubscribe)
145
146
        # populate contextual menu
147
        self.window.ui.treeView.addAction(self.window.ui.actionSubscribeDataChange)
148
        self.window.ui.treeView.addAction(self.window.ui.actionUnsubscribeDataChange)
149
150
        # handle subscriptions
151
        self._subhandler.data_change_fired.connect(self._update_subscription_model, type=Qt.QueuedConnection)
152
        
153
        # accept drops
154
        self.model.canDropMimeData = self.canDropMimeData
155
        self.model.dropMimeData = self.dropMimeData
156
157
    def canDropMimeData(self, mdata, action, row, column, parent):
158
        return True
159
160
    def dropMimeData(self, mdata, action, row, column, parent):
161
        node = self.uaclient.client.get_node(mdata.text())
162
        self._subscribe(node)
163
        return True
164
165
    def clear(self):
166
        self._subscribed_nodes = []
167
        self.model.clear()
168
169
    def show_error(self, *args):
170
        self.window.show_error(*args)
171
172
    @trycatchslot
173
    def _subscribe(self, node=None):
174
        if not isinstance(node, Node):
175
            node = self.window.get_current_node()
176
            if node is None:
177
                return
178
        if node in self._subscribed_nodes:
179
            logger.warning("allready subscribed to node: %s ", node)
180
            return
181
        self.model.setHorizontalHeaderLabels(["DisplayName", "Value", "Timestamp"])
182
        text = str(node.get_display_name().Text)
183
        row = [QStandardItem(text), QStandardItem("No Data yet"), QStandardItem("")]
184
        row[0].setData(node)
185
        self.model.appendRow(row)
186
        self._subscribed_nodes.append(node)
187
        self.window.ui.subDockWidget.raise_()
188
        try:
189
            self.uaclient.subscribe_datachange(node, self._subhandler)
190
        except Exception as ex:
191
            self.window.show_error(ex)
192
            idx = self.model.indexFromItem(row[0])
193
            self.model.takeRow(idx.row())
194
            raise
195
196
    @trycatchslot
197
    def _unsubscribe(self):
198
        node = self.window.get_current_node()
199
        if node is None:
200
            return
201
        self.uaclient.unsubscribe_datachange(node)
202
        self._subscribed_nodes.remove(node)
203
        i = 0
204
        while self.model.item(i):
205
            item = self.model.item(i)
206
            if item.data() == node:
207
                self.model.removeRow(i)
208
            i += 1
209
210
    def _update_subscription_model(self, node, value, timestamp):
211
        i = 0
212
        while self.model.item(i):
213
            item = self.model.item(i)
214
            if item.data() == node:
215
                it = self.model.item(i, 1)
216
                it.setText(value)
217
                it_ts = self.model.item(i, 2)
218
                it_ts.setText(timestamp)
219
            i += 1
220
221
222
class GraphUI(object):
223
224
    # use tango color schema (public domain)
225
    colorCycle = ['#4e9a06ff', '#ce5c00ff', '#3465a4ff', '#75507bff', '#cc0000ff', '#edd400ff']
226
    acceptedDatatypes = ['Decimal128', 'Double', 'Float', 'Integer', 'UInteger']
227
228
    def __init__(self, window, uaclient):
229
        self.window = window
230
        self.uaclient = uaclient
231
232
        # exit if the modules are not present
233
        if not use_graph:
234
            self.window.ui.graphLayout.addWidget(QLabel("pyqtgraph or numpy not installed"))
235
            return
236
        self._node_list = [] # holds the nodes to poll
237
        self._channels = [] # holds the actual data
238
        self._curves = [] # holds the curve objects
239
        self.pw = pg.PlotWidget(name='Plot1')
240
        self.pw.showGrid(x = True, y = True, alpha = 0.3)
241
        self.legend = self.pw.addLegend()
242
        self.window.ui.graphLayout.addWidget(self.pw)
243
244
        self.window.ui.actionAddToGraph.triggered.connect(self._add_node_to_channel)
245
        self.window.ui.actionRemoveFromGraph.triggered.connect(self._remove_node_from_channel)
246
247
        # populate contextual menu
248
        self.window.ui.treeView.addAction(self.window.ui.actionAddToGraph)
249
        self.window.ui.treeView.addAction(self.window.ui.actionRemoveFromGraph)
250
251
        # connect Apply button
252
        self.window.ui.buttonApply.clicked.connect(self.restartTimer)
253
        self.restartTimer()
254
255
256
    def restartTimer(self):
257
        # stop current timer, if it exists
258
        if hasattr(self,'timer') and self.timer.isActive():
259
            self.timer.stop()
260
261
        # define the number of polls displayed in graph
262
        self.N = self.window.ui.spinBoxNumberOfPoints.value()
263
        self.ts = np.arange(self.N)
264
        # define the poll intervall
265
        self.intervall = self.window.ui.spinBoxIntervall.value()*1000
266
267
        # overwrite current channel buffers with zeros of current length and add to curves again
268
        for i,channel in enumerate(self._channels):
269
            self._channels[i] = np.zeros(self.N)
270
            self._curves[i].setData(self._channels[i])
271
272
        # starting new timer
273
        self.timer = QTimer()
274
        self.timer.setInterval(self.intervall)
275
        self.timer.timeout.connect(self.pushtoGraph)
276
        self.timer.start()
277
        
278
    @trycatchslot
279
    def _add_node_to_channel(self,node=None):
280
        # TODO: check if node is of number type
281
        if not isinstance(node, Node):
282
            node = self.window.get_current_node()
283
            if node is None:
284
                return
285
        if node not in self._node_list:
286
            dtype = node.get_attribute(ua.AttributeIds.DataType)
287
288
            dtypeStr = ua.ObjectIdNames[dtype.Value.Value.Identifier]
289
290
            #TODO: arrays are detected by checking if the value is a list, but should use get_array_dimensions
291
            #TODO:  instead. Unfortunately get_array_dimensions always returns an empty list.
292
            if dtypeStr in self.acceptedDatatypes and not isinstance(node.get_value(),list):
293
                self._node_list.append(node)
294
                displayName = node.get_display_name().Text.decode('utf-8')
295
                colorIndex = len(self._node_list) % len(self.colorCycle)
296
                self._curves.append(self.pw.plot(pen=pg.mkPen(color=self.colorCycle[colorIndex],width=3,style=Qt.SolidLine), name=displayName))
297
                # set initial data to zero
298
                self._channels.append(np.zeros(self.N)) # init data sequence with zeros
299
                # add the new channel data to the new curve
300
                self._curves[-1].setData(self._channels[-1])
301
                logger.info("Variable %s added to graph", displayName)
302
303
            else:
304
                logger.info("Variable cannot be added to graph because it is of type %s or an array", dtypeStr)
305
306
307
    @trycatchslot
308
    def _remove_node_from_channel(self,node=None):
309
        if not isinstance(node, Node):
310
            node = self.window.get_current_node()
311
            if node is None:
312
                return
313
        if node in self._node_list:
314
            idx = self._node_list.index(node)
315
            self._node_list.pop(idx)
316
            displayName = node.get_display_name().Text.decode('utf-8')
317
            self.legend.removeItem(displayName)
318
            self.pw.removeItem(self._curves[idx])
319
            self._curves.pop(idx)
320
            self._channels.pop(idx)
321
 
322
323
    def pushtoGraph(self):
324
        # ringbuffer: shift and replace last
325
        for i,node in enumerate(self._node_list):
326
            self._channels[i] = np.roll(self._channels[i],-1) # shift elements to the left by one
327
            self._channels[i][-1] = float(node.get_value())
328
            self._curves[i].setData(self.ts,self._channels[i])
329
330
331
    def clear(self):
332
        pass
333
       
334
335
    def show_error(self, *args):
336
        self.window.show_error(*args)
337
338
class Window(QMainWindow):
339
340
    def __init__(self):
341
        QMainWindow.__init__(self)
342
        self.ui = Ui_MainWindow()
343
        self.ui.setupUi(self)
344
        self.setWindowIcon(QIcon(":/network.svg"))
345
346
        # fix stuff imposible to do in qtdesigner
347
        # remove dock titlebar for addressbar
348
        w = QWidget()
349
        self.ui.addrDockWidget.setTitleBarWidget(w)
350
        # tabify some docks
351
        self.tabifyDockWidget(self.ui.evDockWidget, self.ui.subDockWidget)
352
        self.tabifyDockWidget(self.ui.subDockWidget, self.ui.refDockWidget)
353
        self.tabifyDockWidget(self.ui.refDockWidget, self.ui.graphDockWidget)
354
355
        # we only show statusbar in case of errors
356
        self.ui.statusBar.hide()
357
358
        # setup QSettings for application and get a settings object
359
        QCoreApplication.setOrganizationName("FreeOpcUa")
360
        QCoreApplication.setApplicationName("OpcUaClient")
361
        self.settings = QSettings()
362
363
        self._address_list = self.settings.value("address_list", ["opc.tcp://localhost:4840", "opc.tcp://localhost:53530/OPCUA/SimulationServer/"])
364
        self._address_list_max_count = int(self.settings.value("address_list_max_count", 10))
365
366
        # init widgets
367
        for addr in self._address_list:
368
            self.ui.addrComboBox.insertItem(-1, addr)
369
370
        self.uaclient = UaClient()
371
372
        self.tree_ui = TreeWidget(self.ui.treeView)
373
        self.tree_ui.error.connect(self.show_error)
374
        self.refs_ui = RefsWidget(self.ui.refView)
375
        self.refs_ui.error.connect(self.show_error)
376
        self.attrs_ui = AttrsWidget(self.ui.attrView)
377
        self.attrs_ui.error.connect(self.show_error)
378
        self.datachange_ui = DataChangeUI(self, self.uaclient)
379
        self.event_ui = EventUI(self, self.uaclient)
380
        self.graph_ui = GraphUI(self, self.uaclient)
381
382
        self.ui.addrComboBox.currentTextChanged.connect(self._uri_changed)
383
        self._uri_changed(self.ui.addrComboBox.currentText())  # force update for current value at startup
384
385
        self.ui.treeView.activated.connect(self.show_refs)
386
        self.ui.treeView.clicked.connect(self.show_refs)
387
        self.ui.actionCopyPath.triggered.connect(self.tree_ui.copy_path)
388
        self.ui.actionCopyNodeId.triggered.connect(self.tree_ui.copy_nodeid)
389
        # add items to context menu
390
        self.ui.treeView.addAction(self.ui.actionCopyPath)
391
        self.ui.treeView.addAction(self.ui.actionCopyNodeId)
392
393
        self.ui.treeView.activated.connect(self.show_attrs)
394
        self.ui.treeView.clicked.connect(self.show_attrs)
395
        self.ui.attrRefreshButton.clicked.connect(self.show_attrs)
396
397
        self.resize(int(self.settings.value("main_window_width", 800)), int(self.settings.value("main_window_height", 600)))
398
        data = self.settings.value("main_window_state", None)
399
        if data:
400
            self.restoreState(data)
401
402
        self.ui.connectButton.clicked.connect(self.connect)
403
        self.ui.disconnectButton.clicked.connect(self.disconnect)
404
        # self.ui.treeView.expanded.connect(self._fit)
405
406
        self.ui.actionConnect.triggered.connect(self.connect)
407
        self.ui.actionDisconnect.triggered.connect(self.disconnect)
408
409
        self.ui.connectOptionButton.clicked.connect(self.show_connection_dialog)
410
411
    def _uri_changed(self, uri):
412
        self.uaclient.load_security_settings(uri)
413
414
    def show_connection_dialog(self):
415
        dia = ConnectionDialog(self, self.ui.addrComboBox.currentText())
416
        dia.security_mode = self.uaclient.security_mode
417
        dia.security_policy = self.uaclient.security_policy
418
        dia.certificate_path = self.uaclient.certificate_path
419
        dia.private_key_path = self.uaclient.private_key_path
420
        ret = dia.exec_()
421
        if ret:
422
            self.uaclient.security_mode = dia.security_mode
423
            self.uaclient.security_policy = dia.security_policy
424
            self.uaclient.certificate_path = dia.certificate_path
425
            self.uaclient.private_key_path = dia.private_key_path
426
427
    @trycatchslot
428
    def show_refs(self, idx):
429
        node = self.get_current_node(idx)
430
        if node:
431
            self.refs_ui.show_refs(node)
432
    
433
    @trycatchslot
434
    def show_attrs(self, idx):
435
        if not isinstance(idx, QModelIndex):
436
            idx = None
437
        node = self.get_current_node(idx)
438
        if node:
439
            self.attrs_ui.show_attrs(node)
440
441
    def show_error(self, msg):
442
        logger.warning("showing error: %s")
443
        self.ui.statusBar.show()
444
        self.ui.statusBar.setStyleSheet("QStatusBar { background-color : red; color : black; }")
445
        self.ui.statusBar.showMessage(str(msg))
446
        QTimer.singleShot(1500, self.ui.statusBar.hide)
447
448
    def get_current_node(self, idx=None):
449
        return self.tree_ui.get_current_node(idx)
450
451
    def get_uaclient(self):
452
        return self.uaclient
453
454
    @trycatchslot
455
    def connect(self):
456
        uri = self.ui.addrComboBox.currentText()
457
        try:
458
            self.uaclient.connect(uri)
459
        except Exception as ex:
460
            self.show_error(ex)
461
            raise
462
463
        self._update_address_list(uri)
464
        self.tree_ui.set_root_node(self.uaclient.client.get_root_node())
465
466
    def _update_address_list(self, uri):
467
        if uri == self._address_list[0]:
468
            return
469
        if uri in self._address_list:
470
            self._address_list.remove(uri)
471
        self._address_list.insert(0, uri)
472
        if len(self._address_list) > self._address_list_max_count:
473
            self._address_list.pop(-1)
474
475
    def disconnect(self):
476
        try:
477
            self.uaclient.disconnect()
478
        except Exception as ex:
479
            self.show_error(ex)
480
            raise
481
        finally:
482
            self.tree_ui.clear()
483
            self.refs_ui.clear()
484
            self.attrs_ui.clear()
485
            self.datachange_ui.clear()
486
            self.event_ui.clear()
487
488
    def closeEvent(self, event):
489
        self.tree_ui.save_state()
490
        self.attrs_ui.save_state()
491
        self.refs_ui.save_state()
492
        self.settings.setValue("main_window_width", self.size().width())
493
        self.settings.setValue("main_window_height", self.size().height())
494
        self.settings.setValue("main_window_state", self.saveState())
495
        self.settings.setValue("address_list", self._address_list)
496
        self.disconnect()
497
        event.accept()
498
499
500
def main():
501
    app = QApplication(sys.argv)
502
    client = Window()
503
    handler = QtHandler(client.ui.logTextEdit)
504
    logging.getLogger().addHandler(handler)
505
    logging.getLogger("uaclient").setLevel(logging.INFO)
506
    logging.getLogger("uawidgets").setLevel(logging.INFO)
507
    #logging.getLogger("opcua").setLevel(logging.INFO)  # to enable logging of ua server
508
   
509
    client.show()
510
    sys.exit(app.exec_())
511
512
513
if __name__ == "__main__":
514
    main()
515