diff --git a/.gitignore b/.gitignore index 2d77925..7706128 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ html/* *.pyc pid/* +# ide +.idea diff --git a/bin/secop-gui b/bin/secop-gui new file mode 100755 index 0000000..206a97c --- /dev/null +++ b/bin/secop-gui @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# ***************************************************************************** +# Copyright (c) 2015-2016 by the authors, see LICENSE +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Alexander Lenz +# +# ***************************************************************************** + +import sys +from os import path + +# Add import path for inplace usage +sys.path.insert(0, path.abspath(path.join(path.dirname(__file__), '..'))) + +from secop.gui.mainwindow import MainWindow +from secop import loggers + +from PyQt4.QtGui import QApplication + + +def main(argv=None): + if argv is None: + argv = sys.argv + + loggers.initLogging('gui', 'debug') + + app = QApplication(argv) + + win = MainWindow() + win.show() + + return app.exec_() + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) + diff --git a/secop/__init__.py b/secop/__init__.py index e69de29..0207b9a 100644 --- a/secop/__init__.py +++ b/secop/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# Copyright (c) 2015-2016 by the authors, see LICENSE +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Alexander Lenz +# +# ***************************************************************************** + +import sip +sip.setapi('QString', 2) +sip.setapi('QVariant', 2) diff --git a/secop/client/baseclient.py b/secop/client/baseclient.py index 412ac59..c2ad72d 100644 --- a/secop/client/baseclient.py +++ b/secop/client/baseclient.py @@ -29,6 +29,7 @@ import threading import Queue from secop import loggers +from secop.validators import validator_from_str from secop.lib import mkthread from secop.lib.parsing import parse_time, format_time from secop.protocol.encoding import ENCODERS @@ -137,7 +138,7 @@ class Client(object): describing_data = {} stopflag = False - def __init__(self, opts): + def __init__(self, opts, autoconnect=True): self.log = loggers.log.getChild('client', True) self._cache = dict() if 'device' in opts: @@ -164,13 +165,15 @@ class Client(object): # mapping the modulename to a dict mapping the parameter names to their values # note: the module value is stored as the value of the parameter value # of the module - self.cache = dict() self._syncLock = threading.RLock() self._thread = threading.Thread(target=self._run) self._thread.daemon = True self._thread.start() + if autoconnect: + self.startup() + def _run(self): while not self.stopflag: try: @@ -247,7 +250,7 @@ class Client(object): self.log.warning("deprecated specifier %r" % spec) spec = '%s:value' % spec modname, pname = spec.split(':', 1) - self.cache.setdefault(modname, {})[pname] = Value(*data) + self._cache.setdefault(modname, {})[pname] = Value(*data) if spec in self.callbacks: for func in self.callbacks[spec]: try: @@ -265,6 +268,22 @@ class Client(object): run.add(func) self.single_shots[spec].difference_update(run) + def _getDescribingModuleData(self, module): + return self.describingModulesData[module] + + def _getDescribingParameterData(self, module, parameter): + return self._getDescribingModuleData(module)['parameters'][parameter] + + def _issueDescribe(self): + _, self.equipment_id, self.describing_data = self.communicate( + 'describe') + + for module, moduleData in self.describing_data['modules'].items(): + for parameter, parameterData in moduleData['parameters'].items(): + validator = validator_from_str(parameterData['validator']) + self.describing_data['modules'][module]['parameters'] \ + [parameter]['validator'] = validator + def register_callback(self, module, parameter, cb): self.log.debug( 'registering callback %r for %s:%s' % @@ -343,14 +362,36 @@ class Client(object): # XXX: further notification-callbacks needed ??? def startup(self, async=False): - _, self.equipment_id, self.describing_data = self.communicate( - 'describe') + self._issueDescribe() # always fill our cache self.communicate('activate') # deactivate updates if not wanted if not async: self.communicate('deactivate') + def queryCache(self, module, parameter=None): + result = self._cache.get(module, {}) + + if parameter is not None: + result = result[parameter] + + return result + + def setParameter(self, module, parameter, value): + validator = self._getDescribingParameterData(module, + parameter)['validator'] + + value = validator(value) + self.communicate('change', '%s:%s' % (module, parameter), value) + + @property + def describingData(self): + return self.describing_data + + @property + def describingModulesData(self): + return self.describingData['modules'] + @property def equipmentId(self): return self.equipment_id diff --git a/secop/devices/core.py b/secop/devices/core.py index e0b8e1d..7e89621 100644 --- a/secop/devices/core.py +++ b/secop/devices/core.py @@ -74,7 +74,8 @@ class PARAM(object): readonly=self.readonly, value=self.value, timestamp=self.timestamp, - validator=repr(self.validator), + validator=str(self.validator) if not isinstance( + self.validator, type) else self.validator.__name__ ) diff --git a/secop/gui/__init__.py b/secop/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secop/gui/mainwindow.py b/secop/gui/mainwindow.py new file mode 100644 index 0000000..67727a0 --- /dev/null +++ b/secop/gui/mainwindow.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# Copyright (c) 2015-2016 by the authors, see LICENSE +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Alexander Lenz +# +# ***************************************************************************** + +from PyQt4.QtGui import QMainWindow, QInputDialog, QTreeWidgetItem +from PyQt4.QtCore import pyqtSignature as qtsig, QObject, pyqtSignal + +from secop.gui.util import loadUi +from secop.gui.nodectrl import NodeCtrl +from secop.gui.modulectrl import ModuleCtrl +from secop.client.baseclient import Client as SECNode + +ITEM_TYPE_NODE = QTreeWidgetItem.UserType + 1 +ITEM_TYPE_MODULE = QTreeWidgetItem.UserType + 2 +ITEM_TYPE_PARAMETER = QTreeWidgetItem.UserType + 3 + + +class QSECNode(SECNode, QObject): + newData = pyqtSignal(str, str, object) # module, parameter, data + + def __init__(self, opts, autoconnect=False, parent=None): + SECNode.__init__(self, opts, autoconnect) + QObject.__init__(self, parent) + + self.startup(True) + self._subscribeCallbacks() + + def _subscribeCallbacks(self): + for module in self.modules: + self._subscribeModuleCallback(module) + + def _subscribeModuleCallback(self, module): + for parameter in self.getParameters(module): + self._subscribeParameterCallback(module, parameter) + + def _subscribeParameterCallback(self, module, parameter): + self.register_callback(module, parameter, self._newDataReceived) + + def _newDataReceived(self, module, parameter, data): + self.newData.emit(module, parameter, data) + + +class MainWindow(QMainWindow): + + def __init__(self, parent=None): + super(MainWindow, self).__init__(parent) + + loadUi(self, 'mainwindow.ui') + + self.splitter.setStretchFactor(0, 1) + self.splitter.setStretchFactor(1, 70) + + self._nodes = {} + self._nodeCtrls = {} + self._currentWidget = self.splitter.widget(1).layout().takeAt(0) + + # add localhost if available + self._addNode('localhost') + + @qtsig('') + def on_actionAdd_SEC_node_triggered(self): + host, ok = QInputDialog.getText(self, 'Add SEC node', + 'Enter SEC node hostname:') + + if not ok: + return + + self._addNode(host) + + def on_treeWidget_currentItemChanged(self, current, previous): + if current.type() == ITEM_TYPE_NODE: + self._displayNode(current.text(0)) + elif current.type() == ITEM_TYPE_MODULE: + self._displayModule(current.parent().text(0), current.text(0)) + + def _addNode(self, host): + + # create client + port = 10767 + if ':' in host: + host, port = host.split(':', 1) + port = int(port) + node = QSECNode({'connectto':host, 'port':port}, parent=self) + host = '%s:%d' % (host, port) + + self._nodes[host] = node + + # fill tree + nodeItem = QTreeWidgetItem(None, [host], ITEM_TYPE_NODE) + + for module in sorted(node.modules): + moduleItem = QTreeWidgetItem(nodeItem, [module], ITEM_TYPE_MODULE) + for param in sorted(node.getParameters(module)): + paramItem = QTreeWidgetItem(moduleItem, [param], + ITEM_TYPE_PARAMETER) + + self.treeWidget.addTopLevelItem(nodeItem) + + def _displayNode(self, node): + + ctrl = self._nodeCtrls.get(node, None) + + if ctrl is None: + ctrl = self._nodeCtrls[node] = NodeCtrl(self._nodes[node]) + + self._replaceCtrlWidget(ctrl) + + def _displayModule(self, node, module): + self._replaceCtrlWidget(ModuleCtrl(self._nodes[node], module)) + + def _replaceCtrlWidget(self, new): + old = self.splitter.widget(1).layout().takeAt(0) + if old: + old.widget().hide() + self.splitter.widget(1).layout().addWidget(new) + new.show() + self._currentWidget = new diff --git a/secop/gui/modulectrl.py b/secop/gui/modulectrl.py new file mode 100644 index 0000000..dc52c39 --- /dev/null +++ b/secop/gui/modulectrl.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# Copyright (c) 2015-2016 by the authors, see LICENSE +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Alexander Lenz +# +# ***************************************************************************** + +from PyQt4.QtGui import QWidget, QLabel +from PyQt4.QtCore import pyqtSignature as qtsig, Qt, pyqtSignal + +from secop.gui.util import loadUi + +class ParameterButtons(QWidget): + setRequested = pyqtSignal(str, str, str) # module, parameter, setpoint + + def __init__(self, module, parameter, initval='', parent=None): + super(ParameterButtons, self).__init__(parent) + loadUi(self, 'parambuttons.ui') + + self._module = module + self._parameter = parameter + + self.currentLineEdit.setText(str(initval)) + + def on_setPushButton_clicked(self): + self.setRequested.emit(self._module, self._parameter, + self.setLineEdit.text()) + + +class ModuleCtrl(QWidget): + def __init__(self, node, module, parent=None): + super(ModuleCtrl, self).__init__(parent) + loadUi(self, 'modulectrl.ui') + self._node = node + self._module = module + + self._paramWidgets = {} # widget cache do avoid garbage collection + + self.moduleNameLabel.setText(module) + self._initModuleWidgets() + + self._node.newData.connect(self._updateValue) + + def _initModuleWidgets(self): + initValues = self._node.queryCache(self._module) + row = 0 + + font = self.font() + font.setBold(True) + + for param in sorted(self._node.getParameters(self._module)): + label = QLabel(param + ':') + label.setFont(font) + + buttons = ParameterButtons(self._module, param, + initValues[param].value) + buttons.setRequested.connect(self._node.setParameter) + + self.paramGroupBox.layout().addWidget(label, row, 0) + self.paramGroupBox.layout().addWidget(buttons, row, 1) + + self._paramWidgets[param] = (label, buttons) + + row += 1 + + def _updateValue(self, module, parameter, value): + if module != self._module: + return + + self._paramWidgets[parameter][1].currentLineEdit.setText(str(value[0])) diff --git a/secop/gui/nodectrl.py b/secop/gui/nodectrl.py new file mode 100644 index 0000000..ab5f788 --- /dev/null +++ b/secop/gui/nodectrl.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# Copyright (c) 2015-2016 by the authors, see LICENSE +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Alexander Lenz +# +# ***************************************************************************** + +import pprint + +from PyQt4.QtGui import QWidget, QTextCursor, QFont, QFontMetrics +from PyQt4.QtCore import pyqtSignature as qtsig, Qt + +from secop.gui.util import loadUi + +class NodeCtrl(QWidget): + def __init__(self, node, parent=None): + super(NodeCtrl, self).__init__(parent) + loadUi(self, 'nodectrl.ui') + + self._node = node + + self.contactPointLabel.setText(self._node.contactPoint) + self.equipmentIdLabel.setText(self._node.equipmentId) + self.protocolVersionLabel.setText(self._node.protocolVersion) + self._clearLog() + + @qtsig('') + def on_sendPushButton_clicked(self): + msg = self.msgLineEdit.text().strip() + + if not msg: + return + + self._addLogEntry('Request: ' + '%s:' % msg, raw=True) + msg = msg.split(' ', 2) + reply = self._node.syncCommunicate(*msg) + self._addLogEntry(reply, newline=True, pretty=True) + + @qtsig('') + def on_clearPushButton_clicked(self): + self._clearLog() + + def _clearLog(self): + self.logTextBrowser.clear() + + self._addLogEntry('SECoP Communication Shell') + self._addLogEntry('=========================') + self._addLogEntry('', newline=True) + + def _addLogEntry(self, msg, newline=False, pretty=False, raw=False): + + if pretty: + msg = pprint.pformat(msg, width=self._getLogWidth()) + + if not raw: + msg = '
%s
' % Qt.escape(str(msg)).replace('\n', '
') + + content = '' + if self.logTextBrowser.toPlainText(): + content = self.logTextBrowser.toHtml() + content += msg + + if newline: + content += '
' + + self.logTextBrowser.setHtml(content) + self.logTextBrowser.moveCursor(QTextCursor.End) + + def _getLogWidth(self): + fontMetrics = QFontMetrics(QFont('Monospace')) + # calculate max avail characters by using an a (which is possible + # due to monospace) + result = self.logTextBrowser.width() / fontMetrics.width('a') + return result + diff --git a/secop/gui/ui/mainwindow.ui b/secop/gui/ui/mainwindow.ui new file mode 100644 index 0000000..aea8b85 --- /dev/null +++ b/secop/gui/ui/mainwindow.ui @@ -0,0 +1,118 @@ + + + MainWindow + + + + 0 + 0 + 1228 + 600 + + + + secop-gui + + + + + + + Qt::Horizontal + + + + + + + + + + + 200 + 0 + + + + false + + + + 1 + + + + + + + + + + + + + + + + + 0 + 0 + 1228 + 25 + + + + + File + + + + + + + + Help + + + + + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + Add SEC node + + + + + Exit + + + + + About + + + + + About Qt + + + + + + diff --git a/secop/gui/ui/modulectrl.ui b/secop/gui/ui/modulectrl.ui new file mode 100644 index 0000000..cd7ea7d --- /dev/null +++ b/secop/gui/ui/modulectrl.ui @@ -0,0 +1,99 @@ + + + Form + + + + 0 + 0 + 230 + 121 + + + + Form + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + 75 + false + true + + + + Module name: + + + + + + + TextLabel + + + + + + + + + Parameters: + + + + 0 + + + 6 + + + 0 + + + 6 + + + 6 + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + + diff --git a/secop/gui/ui/nodectrl.ui b/secop/gui/ui/nodectrl.ui new file mode 100644 index 0000000..f0b1c43 --- /dev/null +++ b/secop/gui/ui/nodectrl.ui @@ -0,0 +1,153 @@ + + + Form + + + + 0 + 0 + 652 + 490 + + + + + 652 + 490 + + + + Form + + + + + + + + + 75 + true + + + + Contact point: + + + + + + + TextLabel + + + + + + + + 75 + true + + + + Equipment ID: + + + + + + + TextLabel + + + + + + + + 75 + true + + + + Protocol version: + + + + + + + TextLabel + + + + + + + + + + + + + + >>> + + + + + + + Send + + + false + + + false + + + + + + + Clear + + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'Sans'; font-size:11pt; font-weight:400; font-style:normal;"> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html> + + + + + + + + + + + msgLineEdit + returnPressed() + sendPushButton + animateClick() + + + 387 + 459 + + + 498 + 462 + + + + + diff --git a/secop/gui/ui/parambuttons.ui b/secop/gui/ui/parambuttons.ui new file mode 100644 index 0000000..2ec97d4 --- /dev/null +++ b/secop/gui/ui/parambuttons.ui @@ -0,0 +1,74 @@ + + + Form + + + + 0 + 0 + 730 + 31 + + + + Form + + + + 6 + + + 0 + + + 0 + + + + + Current: + + + + + + + false + + + + 256 + 0 + + + + + + + + Set: + + + + + + + + 256 + 0 + + + + + + + + Set + + + + + + + + diff --git a/secop/gui/util.py b/secop/gui/util.py new file mode 100644 index 0000000..15015e2 --- /dev/null +++ b/secop/gui/util.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# Copyright (c) 2015-2016 by the authors, see LICENSE +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Alexander Lenz +# +# ***************************************************************************** + +from os import path + +from PyQt4 import uic + + +uipath = path.dirname(__file__) + + +def loadUi(widget, uiname, subdir='ui'): + uic.loadUi(path.join(uipath, subdir, uiname), widget) diff --git a/secop/lib/__init__.py b/secop/lib/__init__.py index 1dd7b08..a5cbb89 100644 --- a/secop/lib/__init__.py +++ b/secop/lib/__init__.py @@ -61,6 +61,7 @@ def mkthread(func, *args, **kwds): t.start() return t + if __name__ == '__main__': print "minimal testing: lib" d = attrdict(a=1, b=2) diff --git a/secop/protocol/dispatcher.py b/secop/protocol/dispatcher.py index 2cc8238..56c076d 100644 --- a/secop/protocol/dispatcher.py +++ b/secop/protocol/dispatcher.py @@ -278,7 +278,7 @@ class Dispatcher(object): def _getParamValue(self, modulename, pname): moduleobj = self.get_module(modulename) if moduleobj is None: - raise NoSuchmoduleError(module=modulename) + raise NoSuchModuleError(module=modulename) pobj = moduleobj.PARAMS.get(pname, None) if pobj is None: diff --git a/secop/validators.py b/secop/validators.py index b07520c..dfa6377 100644 --- a/secop/validators.py +++ b/secop/validators.py @@ -30,6 +30,7 @@ # if a validator does a mapping, it normally maps to the external representation (used for print/log/protocol/...) # to get the internal representation (for the code), call method convert + class ProgrammingError(Exception): pass @@ -156,7 +157,7 @@ class vector(object): def __init__(self, *args): self.validators = args - self.argstr = ', '.join([repr(e) for e in args]) + self.argstr = ', '.join([validator_to_str(e) for e in args]) def __call__(self, args): if len(args) != len(self.validators): @@ -173,7 +174,7 @@ class record(object): def __init__(self, **kwds): self.validators = args - self.argstr = ', '.join([repr(e) for e in kwds.items()]) + self.argstr = ', '.join([validator_to_str(e) for e in kwds.items()]) def __call__(self, arg): if len(args) != len(self.validators): @@ -190,7 +191,7 @@ class oneof(object): def __init__(self, *args): self.oneof = args - self.argstr = ', '.join([repr(e) for e in args]) + self.argstr = ', '.join([validator_to_str(e) for e in args]) def __call__(self, arg): for v in self.oneof: @@ -247,3 +248,12 @@ class enum(object): def convert(self, arg): return self.mapping.get(arg, arg) + + +def validator_to_str(validator): + return str(validator) if not isinstance(validator, type) \ + else validator.__name__ + + +def validator_from_str(validator_str): + return eval(validator_str)