1 | #! /usr/bin/env python3 |
||
2 | |||
3 | import sys |
||
4 | |||
5 | from datetime import datetime |
||
6 | import inspect |
||
7 | from enum import Enum |
||
8 | import logging |
||
9 | |||
10 | from PyQt5.QtCore import pyqtSignal, QTimer, Qt, QObject, QSettings, QItemSelection, QMimeData, QCoreApplication |
||
11 | from PyQt5.QtGui import QStandardItemModel, QStandardItem, QIcon |
||
12 | from PyQt5.QtWidgets import QMainWindow, QWidget, QApplication, QAbstractItemView, QMenu, QAction |
||
13 | |||
14 | from opcua import ua |
||
15 | from opcua import Node |
||
16 | |||
17 | from uaclient.uaclient import UaClient |
||
18 | from uaclient.mainwindow_ui import Ui_MainWindow |
||
19 | from uaclient.connection_dialog import ConnectionDialog |
||
20 | from uaclient.graphwidget import GraphUI |
||
21 | |||
22 | from uawidgets import resources |
||
23 | from uawidgets.attrs_widget import AttrsWidget |
||
24 | from uawidgets.tree_widget import TreeWidget |
||
25 | from uawidgets.refs_widget import RefsWidget |
||
26 | from uawidgets.utils import trycatchslot |
||
27 | from uawidgets.logger import QtHandler |
||
28 | from uawidgets.call_method_dialog import CallMethodDialog |
||
29 | |||
30 | |||
31 | logger = logging.getLogger(__name__) |
||
32 | |||
33 | |||
34 | class DataChangeHandler(QObject): |
||
35 | data_change_fired = pyqtSignal(object, str, str) |
||
36 | |||
37 | def datachange_notification(self, node, val, data): |
||
38 | if data.monitored_item.Value.SourceTimestamp: |
||
39 | dato = data.monitored_item.Value.SourceTimestamp.isoformat() |
||
40 | elif data.monitored_item.Value.ServerTimestamp: |
||
41 | dato = data.monitored_item.Value.ServerTimestamp.isoformat() |
||
42 | else: |
||
43 | dato = datetime.now().isoformat() |
||
44 | self.data_change_fired.emit(node, str(val), dato) |
||
45 | |||
46 | |||
47 | class EventHandler(QObject): |
||
48 | event_fired = pyqtSignal(object) |
||
49 | |||
50 | def event_notification(self, event): |
||
51 | self.event_fired.emit(event) |
||
52 | |||
53 | |||
54 | class EventUI(object): |
||
55 | |||
56 | View Code Duplication | def __init__(self, window, uaclient): |
|
0 ignored issues
–
show
Duplication
introduced
by
![]() |
|||
57 | self.window = window |
||
58 | self.uaclient = uaclient |
||
59 | self._handler = EventHandler() |
||
60 | self._subscribed_nodes = [] # FIXME: not really needed |
||
61 | self.model = QStandardItemModel() |
||
62 | self.window.ui.evView.setModel(self.model) |
||
63 | self.window.ui.actionSubscribeEvent.triggered.connect(self._subscribe) |
||
64 | self.window.ui.actionUnsubscribeEvents.triggered.connect(self._unsubscribe) |
||
65 | # context menu |
||
66 | self.window.addAction(self.window.ui.actionSubscribeEvent) |
||
67 | self.window.addAction(self.window.ui.actionUnsubscribeEvents) |
||
68 | self.window.addAction(self.window.ui.actionAddToGraph) |
||
69 | self._handler.event_fired.connect(self._update_event_model, type=Qt.QueuedConnection) |
||
70 | |||
71 | # accept drops |
||
72 | self.model.canDropMimeData = self.canDropMimeData |
||
73 | self.model.dropMimeData = self.dropMimeData |
||
74 | |||
75 | def canDropMimeData(self, mdata, action, row, column, parent): |
||
76 | return True |
||
77 | |||
78 | def show_error(self, *args): |
||
79 | self.window.show_error(*args) |
||
80 | |||
81 | def dropMimeData(self, mdata, action, row, column, parent): |
||
82 | node = self.uaclient.client.get_node(mdata.text()) |
||
83 | self._subscribe(node) |
||
84 | return True |
||
85 | |||
86 | def clear(self): |
||
87 | self._subscribed_nodes = [] |
||
88 | self.model.clear() |
||
89 | |||
90 | @trycatchslot |
||
91 | def _subscribe(self, node=None): |
||
92 | logger.info("Subscribing to %s", node) |
||
93 | if not node: |
||
94 | node = self.window.get_current_node() |
||
95 | if node is None: |
||
96 | return |
||
97 | if node in self._subscribed_nodes: |
||
98 | logger.info("already subscribed to event for node: %s", node) |
||
99 | return |
||
100 | logger.info("Subscribing to events for %s", node) |
||
101 | self.window.ui.evDockWidget.raise_() |
||
102 | try: |
||
103 | self.uaclient.subscribe_events(node, self._handler) |
||
104 | except Exception as ex: |
||
105 | self.window.show_error(ex) |
||
106 | raise |
||
107 | else: |
||
108 | self._subscribed_nodes.append(node) |
||
109 | |||
110 | @trycatchslot |
||
111 | def _unsubscribe(self): |
||
112 | node = self.window.get_current_node() |
||
113 | if node is None: |
||
114 | return |
||
115 | self._subscribed_nodes.remove(node) |
||
116 | self.uaclient.unsubscribe_events(node) |
||
117 | |||
118 | @trycatchslot |
||
119 | def _update_event_model(self, event): |
||
120 | self.model.appendRow([QStandardItem(str(event))]) |
||
121 | |||
122 | |||
123 | class DataChangeUI(object): |
||
124 | |||
125 | View Code Duplication | def __init__(self, window, uaclient): |
|
0 ignored issues
–
show
|
|||
126 | self.window = window |
||
127 | self.uaclient = uaclient |
||
128 | self._subhandler = DataChangeHandler() |
||
129 | self._subscribed_nodes = [] |
||
130 | self.model = QStandardItemModel() |
||
131 | self.window.ui.subView.setModel(self.model) |
||
132 | self.window.ui.subView.horizontalHeader().setSectionResizeMode(1) |
||
133 | |||
134 | self.window.ui.actionSubscribeDataChange.triggered.connect(self._subscribe) |
||
135 | self.window.ui.actionUnsubscribeDataChange.triggered.connect(self._unsubscribe) |
||
136 | |||
137 | # populate contextual menu |
||
138 | self.window.addAction(self.window.ui.actionSubscribeDataChange) |
||
139 | self.window.addAction(self.window.ui.actionUnsubscribeDataChange) |
||
140 | |||
141 | # handle subscriptions |
||
142 | self._subhandler.data_change_fired.connect(self._update_subscription_model, type=Qt.QueuedConnection) |
||
143 | |||
144 | # accept drops |
||
145 | self.model.canDropMimeData = self.canDropMimeData |
||
146 | self.model.dropMimeData = self.dropMimeData |
||
147 | |||
148 | def canDropMimeData(self, mdata, action, row, column, parent): |
||
149 | return True |
||
150 | |||
151 | def dropMimeData(self, mdata, action, row, column, parent): |
||
152 | node = self.uaclient.client.get_node(mdata.text()) |
||
153 | self._subscribe(node) |
||
154 | return True |
||
155 | |||
156 | def clear(self): |
||
157 | self._subscribed_nodes = [] |
||
158 | self.model.clear() |
||
159 | |||
160 | def show_error(self, *args): |
||
161 | self.window.show_error(*args) |
||
162 | |||
163 | @trycatchslot |
||
164 | def _subscribe(self, node=None): |
||
165 | if not isinstance(node, Node): |
||
166 | node = self.window.get_current_node() |
||
167 | if node is None: |
||
168 | return |
||
169 | if node in self._subscribed_nodes: |
||
170 | logger.warning("allready subscribed to node: %s ", node) |
||
171 | return |
||
172 | self.model.setHorizontalHeaderLabels(["DisplayName", "Value", "Timestamp"]) |
||
173 | text = str(node.get_display_name().Text) |
||
174 | row = [QStandardItem(text), QStandardItem("No Data yet"), QStandardItem("")] |
||
175 | row[0].setData(node) |
||
176 | self.model.appendRow(row) |
||
177 | self._subscribed_nodes.append(node) |
||
178 | self.window.ui.subDockWidget.raise_() |
||
179 | try: |
||
180 | self.uaclient.subscribe_datachange(node, self._subhandler) |
||
181 | except Exception as ex: |
||
182 | self.window.show_error(ex) |
||
183 | idx = self.model.indexFromItem(row[0]) |
||
184 | self.model.takeRow(idx.row()) |
||
185 | raise |
||
186 | |||
187 | @trycatchslot |
||
188 | def _unsubscribe(self): |
||
189 | node = self.window.get_current_node() |
||
190 | if node is None: |
||
191 | return |
||
192 | self.uaclient.unsubscribe_datachange(node) |
||
193 | self._subscribed_nodes.remove(node) |
||
194 | i = 0 |
||
195 | while self.model.item(i): |
||
196 | item = self.model.item(i) |
||
197 | if item.data() == node: |
||
198 | self.model.removeRow(i) |
||
199 | i += 1 |
||
200 | |||
201 | def _update_subscription_model(self, node, value, timestamp): |
||
202 | i = 0 |
||
203 | while self.model.item(i): |
||
204 | item = self.model.item(i) |
||
205 | if item.data() == node: |
||
206 | it = self.model.item(i, 1) |
||
207 | it.setText(value) |
||
208 | it_ts = self.model.item(i, 2) |
||
209 | it_ts.setText(timestamp) |
||
210 | i += 1 |
||
211 | |||
212 | |||
213 | class Window(QMainWindow): |
||
214 | |||
215 | def __init__(self): |
||
216 | QMainWindow.__init__(self) |
||
217 | self.ui = Ui_MainWindow() |
||
218 | self.ui.setupUi(self) |
||
219 | self.setWindowIcon(QIcon(":/network.svg")) |
||
220 | |||
221 | # fix stuff imposible to do in qtdesigner |
||
222 | # remove dock titlebar for addressbar |
||
223 | w = QWidget() |
||
224 | self.ui.addrDockWidget.setTitleBarWidget(w) |
||
225 | # tabify some docks |
||
226 | self.tabifyDockWidget(self.ui.evDockWidget, self.ui.subDockWidget) |
||
227 | self.tabifyDockWidget(self.ui.subDockWidget, self.ui.refDockWidget) |
||
228 | self.tabifyDockWidget(self.ui.refDockWidget, self.ui.graphDockWidget) |
||
229 | |||
230 | # we only show statusbar in case of errors |
||
231 | self.ui.statusBar.hide() |
||
232 | |||
233 | # setup QSettings for application and get a settings object |
||
234 | QCoreApplication.setOrganizationName("FreeOpcUa") |
||
235 | QCoreApplication.setApplicationName("OpcUaClient") |
||
236 | self.settings = QSettings() |
||
237 | |||
238 | self._address_list = self.settings.value("address_list", ["opc.tcp://localhost:4840", "opc.tcp://localhost:53530/OPCUA/SimulationServer/"]) |
||
239 | print("ADR", self._address_list) |
||
240 | self._address_list_max_count = int(self.settings.value("address_list_max_count", 10)) |
||
241 | |||
242 | # init widgets |
||
243 | for addr in self._address_list: |
||
244 | self.ui.addrComboBox.insertItem(100, addr) |
||
245 | |||
246 | self.uaclient = UaClient() |
||
247 | |||
248 | self.tree_ui = TreeWidget(self.ui.treeView) |
||
249 | self.tree_ui.error.connect(self.show_error) |
||
250 | self.setup_context_menu_tree() |
||
251 | self.ui.treeView.selectionModel().currentChanged.connect(self._update_actions_state) |
||
252 | |||
253 | self.refs_ui = RefsWidget(self.ui.refView) |
||
254 | self.refs_ui.error.connect(self.show_error) |
||
255 | self.attrs_ui = AttrsWidget(self.ui.attrView) |
||
256 | self.attrs_ui.error.connect(self.show_error) |
||
257 | self.datachange_ui = DataChangeUI(self, self.uaclient) |
||
258 | self.event_ui = EventUI(self, self.uaclient) |
||
259 | self.graph_ui = GraphUI(self, self.uaclient) |
||
260 | |||
261 | self.ui.addrComboBox.currentTextChanged.connect(self._uri_changed) |
||
262 | self._uri_changed(self.ui.addrComboBox.currentText()) # force update for current value at startup |
||
263 | |||
264 | self.ui.treeView.selectionModel().selectionChanged.connect(self.show_refs) |
||
265 | self.ui.actionCopyPath.triggered.connect(self.tree_ui.copy_path) |
||
266 | self.ui.actionCopyNodeId.triggered.connect(self.tree_ui.copy_nodeid) |
||
267 | self.ui.actionCall.triggered.connect(self.call_method) |
||
268 | |||
269 | self.ui.treeView.selectionModel().selectionChanged.connect(self.show_attrs) |
||
270 | self.ui.attrRefreshButton.clicked.connect(self.show_attrs) |
||
271 | |||
272 | self.resize(int(self.settings.value("main_window_width", 800)), int(self.settings.value("main_window_height", 600))) |
||
273 | data = self.settings.value("main_window_state", None) |
||
274 | if data: |
||
275 | self.restoreState(data) |
||
276 | |||
277 | self.ui.connectButton.clicked.connect(self.connect) |
||
278 | self.ui.disconnectButton.clicked.connect(self.disconnect) |
||
279 | # self.ui.treeView.expanded.connect(self._fit) |
||
280 | |||
281 | self.ui.actionConnect.triggered.connect(self.connect) |
||
282 | self.ui.actionDisconnect.triggered.connect(self.disconnect) |
||
283 | |||
284 | self.ui.connectOptionButton.clicked.connect(self.show_connection_dialog) |
||
285 | |||
286 | def _uri_changed(self, uri): |
||
287 | self.uaclient.load_security_settings(uri) |
||
288 | |||
289 | def show_connection_dialog(self): |
||
290 | dia = ConnectionDialog(self, self.ui.addrComboBox.currentText()) |
||
291 | dia.security_mode = self.uaclient.security_mode |
||
292 | dia.security_policy = self.uaclient.security_policy |
||
293 | dia.certificate_path = self.uaclient.certificate_path |
||
294 | dia.private_key_path = self.uaclient.private_key_path |
||
295 | ret = dia.exec_() |
||
296 | if ret: |
||
297 | self.uaclient.security_mode = dia.security_mode |
||
298 | self.uaclient.security_policy = dia.security_policy |
||
299 | self.uaclient.certificate_path = dia.certificate_path |
||
300 | self.uaclient.private_key_path = dia.private_key_path |
||
301 | |||
302 | @trycatchslot |
||
303 | def show_refs(self, selection): |
||
304 | if isinstance(selection, QItemSelection): |
||
305 | if not selection.indexes(): # no selection |
||
306 | return |
||
307 | |||
308 | node = self.get_current_node() |
||
309 | if node: |
||
310 | self.refs_ui.show_refs(node) |
||
311 | |||
312 | @trycatchslot |
||
313 | def show_attrs(self, selection): |
||
314 | if isinstance(selection, QItemSelection): |
||
315 | if not selection.indexes(): # no selection |
||
316 | return |
||
317 | |||
318 | node = self.get_current_node() |
||
319 | if node: |
||
320 | self.attrs_ui.show_attrs(node) |
||
321 | |||
322 | def show_error(self, msg): |
||
323 | logger.warning("showing error: %s") |
||
324 | self.ui.statusBar.show() |
||
325 | self.ui.statusBar.setStyleSheet("QStatusBar { background-color : red; color : black; }") |
||
326 | self.ui.statusBar.showMessage(str(msg)) |
||
327 | QTimer.singleShot(1500, self.ui.statusBar.hide) |
||
328 | |||
329 | def get_current_node(self, idx=None): |
||
330 | return self.tree_ui.get_current_node(idx) |
||
331 | |||
332 | def get_uaclient(self): |
||
333 | return self.uaclient |
||
334 | |||
335 | @trycatchslot |
||
336 | def connect(self): |
||
337 | uri = self.ui.addrComboBox.currentText() |
||
338 | try: |
||
339 | self.uaclient.connect(uri) |
||
340 | except Exception as ex: |
||
341 | self.show_error(ex) |
||
342 | raise |
||
343 | |||
344 | self._update_address_list(uri) |
||
345 | self.tree_ui.set_root_node(self.uaclient.client.get_root_node()) |
||
346 | self.ui.treeView.setFocus() |
||
347 | self.load_current_node() |
||
348 | |||
349 | def _update_address_list(self, uri): |
||
350 | if uri == self._address_list[0]: |
||
351 | return |
||
352 | if uri in self._address_list: |
||
353 | self._address_list.remove(uri) |
||
354 | self._address_list.insert(0, uri) |
||
355 | if len(self._address_list) > self._address_list_max_count: |
||
356 | self._address_list.pop(-1) |
||
357 | |||
358 | def disconnect(self): |
||
359 | try: |
||
360 | self.uaclient.disconnect() |
||
361 | except Exception as ex: |
||
362 | self.show_error(ex) |
||
363 | raise |
||
364 | finally: |
||
365 | self.save_current_node() |
||
366 | self.tree_ui.clear() |
||
367 | self.refs_ui.clear() |
||
368 | self.attrs_ui.clear() |
||
369 | self.datachange_ui.clear() |
||
370 | self.event_ui.clear() |
||
371 | |||
372 | |||
373 | def closeEvent(self, event): |
||
374 | self.tree_ui.save_state() |
||
375 | self.attrs_ui.save_state() |
||
376 | self.refs_ui.save_state() |
||
377 | self.settings.setValue("main_window_width", self.size().width()) |
||
378 | self.settings.setValue("main_window_height", self.size().height()) |
||
379 | self.settings.setValue("main_window_state", self.saveState()) |
||
380 | self.settings.setValue("address_list", self._address_list) |
||
381 | self.disconnect() |
||
382 | event.accept() |
||
383 | |||
384 | def save_current_node(self): |
||
385 | current_node = self.tree_ui.get_current_node() |
||
386 | if current_node: |
||
387 | mysettings = self.settings.value("current_node", None) |
||
388 | if mysettings is None: |
||
389 | mysettings = {} |
||
390 | uri = self.ui.addrComboBox.currentText() |
||
391 | mysettings[uri] = current_node.nodeid.to_string() |
||
392 | self.settings.setValue("current_node", mysettings) |
||
393 | |||
394 | def load_current_node(self): |
||
395 | mysettings = self.settings.value("current_node", None) |
||
396 | if mysettings is None: |
||
397 | return |
||
398 | uri = self.ui.addrComboBox.currentText() |
||
399 | if uri in mysettings: |
||
400 | nodeid = ua.NodeId.from_string(mysettings[uri]) |
||
401 | node = self.uaclient.client.get_node(nodeid) |
||
402 | self.tree_ui.expand_to_node(node) |
||
403 | |||
404 | def setup_context_menu_tree(self): |
||
405 | self.ui.treeView.setContextMenuPolicy(Qt.CustomContextMenu) |
||
406 | self.ui.treeView.customContextMenuRequested.connect(self._show_context_menu_tree) |
||
407 | self._contextMenu = QMenu() |
||
408 | self.addAction(self.ui.actionCopyPath) |
||
409 | self.addAction(self.ui.actionCopyNodeId) |
||
410 | self._contextMenu.addSeparator() |
||
411 | self._contextMenu.addAction(self.ui.actionCall) |
||
412 | self._contextMenu.addSeparator() |
||
413 | |||
414 | def addAction(self, action): |
||
415 | self._contextMenu.addAction(action) |
||
416 | |||
417 | @trycatchslot |
||
418 | def _update_actions_state(self, current, previous): |
||
419 | node = self.get_current_node(current) |
||
420 | self.ui.actionCall.setEnabled(False) |
||
421 | if node: |
||
422 | if node.get_node_class() == ua.NodeClass.Method: |
||
423 | self.ui.actionCall.setEnabled(True) |
||
424 | |||
425 | def _show_context_menu_tree(self, position): |
||
426 | node = self.tree_ui.get_current_node() |
||
427 | if node: |
||
428 | self._contextMenu.exec_(self.ui.treeView.viewport().mapToGlobal(position)) |
||
429 | |||
430 | def call_method(self): |
||
431 | node = self.get_current_node() |
||
432 | dia = CallMethodDialog(self, self.uaclient.client, node) |
||
433 | dia.show() |
||
434 | |||
435 | |||
436 | def main(): |
||
437 | app = QApplication(sys.argv) |
||
438 | client = Window() |
||
439 | handler = QtHandler(client.ui.logTextEdit) |
||
440 | logging.getLogger().addHandler(handler) |
||
441 | logging.getLogger("uaclient").setLevel(logging.INFO) |
||
442 | logging.getLogger("uawidgets").setLevel(logging.INFO) |
||
443 | #logging.getLogger("opcua").setLevel(logging.INFO) # to enable logging of ua client library |
||
444 | |||
445 | client.show() |
||
446 | sys.exit(app.exec_()) |
||
447 | |||
448 | |||
449 | if __name__ == "__main__": |
||
450 | main() |
||
451 |