Rename from secop to frappy
debian/ is still missing, will follow in next commit. Fixes: #4626 Change-Id: Ia87c28c1c75b8402eedbfca47f888585a7881f44
24
frappy/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- 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 <alexander.lenz@frm2.tum.de>
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
587
frappy/client/__init__.py
Normal file
@@ -0,0 +1,587 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""general SECoP client"""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from threading import Event, RLock, current_thread
|
||||
|
||||
import frappy.errors
|
||||
import frappy.params
|
||||
from frappy.datatypes import get_datatype
|
||||
from frappy.lib import mkthread
|
||||
from frappy.lib.asynconn import AsynConn, ConnectionClosed
|
||||
from frappy.protocol.interface import decode_msg, encode_msg_frame
|
||||
from frappy.protocol.messages import COMMANDREQUEST, \
|
||||
DESCRIPTIONREQUEST, ENABLEEVENTSREQUEST, ERRORPREFIX, \
|
||||
EVENTREPLY, HEARTBEATREQUEST, IDENTPREFIX, IDENTREQUEST, \
|
||||
READREPLY, READREQUEST, REQUEST2REPLY, WRITEREPLY, WRITEREQUEST
|
||||
|
||||
# replies to be handled for cache
|
||||
UPDATE_MESSAGES = {EVENTREPLY, READREPLY, WRITEREPLY, ERRORPREFIX + READREQUEST, ERRORPREFIX + EVENTREPLY}
|
||||
|
||||
|
||||
class UNREGISTER:
|
||||
"""a magic value, used a returned value in a callback
|
||||
|
||||
to indicate it has to be unregistered
|
||||
used to implement one shot callbacks
|
||||
"""
|
||||
|
||||
|
||||
class Logger:
|
||||
"""dummy logger, in case not provided from caller"""
|
||||
|
||||
@staticmethod
|
||||
def info(fmt, *args, **kwds):
|
||||
print(str(fmt) % args)
|
||||
|
||||
@staticmethod
|
||||
def noop(fmt, *args, **kwds):
|
||||
pass
|
||||
|
||||
debug = noop
|
||||
error = warning = critical = info
|
||||
|
||||
|
||||
class CallbackObject:
|
||||
"""abstract definition for a target object for callbacks
|
||||
|
||||
this is mainly for documentation, but it might be extended
|
||||
and used as a mixin for objects registered as a callback
|
||||
"""
|
||||
def updateEvent(self, module, parameter, value, timestamp, readerror):
|
||||
"""called whenever a value is changed
|
||||
|
||||
or when new callbacks are registered
|
||||
"""
|
||||
|
||||
def unhandledMessage(self, action, ident, data):
|
||||
"""called on an unhandled message"""
|
||||
|
||||
def nodeStateChange(self, online, state):
|
||||
"""called when the state of the connection changes
|
||||
|
||||
'online' is True when connected or reconnecting, False when disconnected or connecting
|
||||
'state' is the connection state as a string
|
||||
"""
|
||||
|
||||
def descriptiveDataChange(self, module, description):
|
||||
"""called when the description has changed
|
||||
|
||||
this callback is called on the node with module=None
|
||||
and on every changed module with module==<module name>
|
||||
"""
|
||||
|
||||
|
||||
class ProxyClient:
|
||||
"""common functionality for proxy clients"""
|
||||
|
||||
CALLBACK_NAMES = ('updateEvent', 'descriptiveDataChange', 'nodeStateChange', 'unhandledMessage')
|
||||
online = False # connected or reconnecting since a short time
|
||||
validate_data = False
|
||||
state = 'disconnected' # further possible values: 'connecting', 'reconnecting', 'connected'
|
||||
|
||||
def __init__(self):
|
||||
self.callbacks = {cbname: defaultdict(list) for cbname in self.CALLBACK_NAMES}
|
||||
# caches (module, parameter) = value, timestamp, readerror (internal names!)
|
||||
self.cache = {}
|
||||
|
||||
def register_callback(self, key, *args, **kwds):
|
||||
"""register callback functions
|
||||
|
||||
- key might be either:
|
||||
1) None: general callback (all callbacks)
|
||||
2) <module name>: callbacks related to a module (not called for 'unhandledMessage')
|
||||
3) (<module name>, <parameter name>): callback for specified parameter (only called for 'updateEvent')
|
||||
- all the following arguments are callback functions. The callback name may be
|
||||
given by the keyword, or, for non-keyworded arguments it is taken from the
|
||||
__name__ attribute of the function
|
||||
"""
|
||||
for cbfunc in args:
|
||||
kwds[cbfunc.__name__] = cbfunc
|
||||
for cbname in self.CALLBACK_NAMES:
|
||||
cbfunc = kwds.pop(cbname, None)
|
||||
if not cbfunc:
|
||||
continue
|
||||
cbdict = self.callbacks[cbname]
|
||||
cbdict[key].append(cbfunc)
|
||||
|
||||
# immediately call for some callback types
|
||||
if cbname == 'updateEvent':
|
||||
if key is None:
|
||||
for (mname, pname), data in self.cache.items():
|
||||
cbfunc(mname, pname, *data)
|
||||
else:
|
||||
data = self.cache.get(key, None)
|
||||
if data:
|
||||
cbfunc(*key, *data) # case single parameter
|
||||
else: # case key = module
|
||||
for (mname, pname), data in self.cache.items():
|
||||
if mname == key:
|
||||
cbfunc(mname, pname, *data)
|
||||
elif cbname == 'nodeStateChange':
|
||||
cbfunc(self.online, self.state)
|
||||
if kwds:
|
||||
raise TypeError('unknown callback: %s' % (', '.join(kwds)))
|
||||
|
||||
def unregister_callback(self, key, *args, **kwds):
|
||||
"""unregister a callback
|
||||
|
||||
for the arguments see register_callback
|
||||
"""
|
||||
for cbfunc in args:
|
||||
kwds[cbfunc.__name__] = cbfunc
|
||||
for cbname, func in kwds.items():
|
||||
cblist = self.callbacks[cbname][key]
|
||||
if func in cblist:
|
||||
cblist.remove(func)
|
||||
if not cblist:
|
||||
self.callbacks[cbname].pop(key)
|
||||
|
||||
def callback(self, key, cbname, *args):
|
||||
"""perform callbacks
|
||||
|
||||
key=None:
|
||||
key=<module name>: callbacks for specified module
|
||||
key=(<module name>, <parameter name): callbacks for specified parameter
|
||||
"""
|
||||
cblist = self.callbacks[cbname].get(key, [])
|
||||
self.callbacks[cbname][key] = [cb for cb in cblist if cb(*args) is not UNREGISTER]
|
||||
return bool(cblist)
|
||||
|
||||
def updateValue(self, module, param, value, timestamp, readerror):
|
||||
if readerror:
|
||||
assert isinstance(readerror, Exception)
|
||||
if self.validate_data:
|
||||
try:
|
||||
# try to validate, reason: make enum_members from integers
|
||||
datatype = self.modules[module]['parameters'][param]['datatype']
|
||||
value = datatype(value)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
self.cache[(module, param)] = (value, timestamp, readerror)
|
||||
self.callback(None, 'updateEvent', module, param, value, timestamp, readerror)
|
||||
self.callback(module, 'updateEvent', module, param, value, timestamp, readerror)
|
||||
self.callback((module, param), 'updateEvent', module, param, value, timestamp, readerror)
|
||||
|
||||
|
||||
class SecopClient(ProxyClient):
|
||||
"""a general SECoP client"""
|
||||
reconnect_timeout = 10
|
||||
_running = False
|
||||
_shutdown = False
|
||||
_rxthread = None
|
||||
_txthread = None
|
||||
_connthread = None
|
||||
disconnect_time = 0 # time of last disconnect
|
||||
secop_version = ''
|
||||
descriptive_data = {}
|
||||
modules = {}
|
||||
_last_error = None
|
||||
|
||||
def __init__(self, uri, log=Logger):
|
||||
super().__init__()
|
||||
# maps expected replies to [request, Event, is_error, result] until a response came
|
||||
# there can only be one entry per thread calling 'request'
|
||||
self.active_requests = {}
|
||||
self.io = None
|
||||
self.txq = queue.Queue(30) # queue for tx requests
|
||||
self.pending = queue.Queue(30) # requests with colliding action + ident
|
||||
self.log = log
|
||||
self.uri = uri
|
||||
self.nodename = uri
|
||||
self._lock = RLock()
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
self.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def connect(self, try_period=0):
|
||||
"""establish connection
|
||||
|
||||
if a <try_period> is given, repeat trying for the given time (sec)
|
||||
"""
|
||||
with self._lock:
|
||||
if self.io:
|
||||
return
|
||||
if self.online:
|
||||
self._set_state(True, 'reconnecting')
|
||||
else:
|
||||
self._set_state(False, 'connecting')
|
||||
deadline = time.time() + try_period
|
||||
while not self._shutdown:
|
||||
try:
|
||||
self.io = AsynConn(self.uri) # timeout 1 sec
|
||||
self.io.writeline(IDENTREQUEST.encode('utf-8'))
|
||||
reply = self.io.readline(10)
|
||||
if reply:
|
||||
self.secop_version = reply.decode('utf-8')
|
||||
else:
|
||||
raise self.error_map('HardwareError')('no answer to %s' % IDENTREQUEST)
|
||||
if not self.secop_version.startswith(IDENTPREFIX):
|
||||
raise self.error_map('HardwareError')('bad answer to %s: %r' %
|
||||
(IDENTREQUEST, self.secop_version))
|
||||
# now its safe to do secop stuff
|
||||
self._running = True
|
||||
self._rxthread = mkthread(self.__rxthread)
|
||||
self._txthread = mkthread(self.__txthread)
|
||||
self.log.debug('connected to %s', self.uri)
|
||||
# pylint: disable=unsubscriptable-object
|
||||
self._init_descriptive_data(self.request(DESCRIPTIONREQUEST)[2])
|
||||
self.nodename = self.properties.get('equipment_id', self.uri)
|
||||
if self.activate:
|
||||
self.request(ENABLEEVENTSREQUEST)
|
||||
self._set_state(True, 'connected')
|
||||
break
|
||||
except Exception:
|
||||
# print(formatExtendedTraceback())
|
||||
if time.time() > deadline:
|
||||
# stay online for now, if activated
|
||||
self._set_state(self.online and self.activate)
|
||||
raise
|
||||
time.sleep(1)
|
||||
if not self._shutdown:
|
||||
self.log.info('%s ready', self.nodename)
|
||||
|
||||
def __txthread(self):
|
||||
while self._running:
|
||||
entry = self.txq.get()
|
||||
if entry is None:
|
||||
break
|
||||
request = entry[0]
|
||||
reply_action = REQUEST2REPLY.get(request[0], None)
|
||||
if reply_action:
|
||||
key = (reply_action, request[1]) # action and identifier
|
||||
else: # allow experimental unknown requests, but only one at a time
|
||||
key = None
|
||||
if key in self.active_requests:
|
||||
# store to requeue after the next reply was received
|
||||
self.pending.put(entry)
|
||||
else:
|
||||
self.active_requests[key] = entry
|
||||
line = encode_msg_frame(*request)
|
||||
self.log.debug('TX: %r', line)
|
||||
self.io.send(line)
|
||||
self._txthread = None
|
||||
self.disconnect(False)
|
||||
|
||||
def __rxthread(self):
|
||||
noactivity = 0
|
||||
try:
|
||||
while self._running:
|
||||
# may raise ConnectionClosed
|
||||
reply = self.io.readline()
|
||||
if reply is None:
|
||||
noactivity += 1
|
||||
if noactivity % 5 == 0:
|
||||
# send ping to check if the connection is still alive
|
||||
self.queue_request(HEARTBEATREQUEST, str(noactivity))
|
||||
continue
|
||||
noactivity = 0
|
||||
action, ident, data = decode_msg(reply)
|
||||
if ident == '.':
|
||||
ident = None
|
||||
if action in UPDATE_MESSAGES:
|
||||
module_param = self.internal.get(ident, None)
|
||||
if module_param is None and ':' not in ident:
|
||||
# allow missing ':value'/':target'
|
||||
if action == WRITEREPLY:
|
||||
module_param = self.internal.get(ident + ':target', None)
|
||||
else:
|
||||
module_param = self.internal.get(ident + ':value', None)
|
||||
if module_param is not None:
|
||||
if action.startswith(ERRORPREFIX):
|
||||
timestamp = data[2].get('t', None)
|
||||
readerror = frappy.errors.make_secop_error(*data[0:2])
|
||||
value = None
|
||||
else:
|
||||
timestamp = data[1].get('t', None)
|
||||
value = data[0]
|
||||
readerror = None
|
||||
module, param = module_param
|
||||
try:
|
||||
self.updateValue(module, param, value, timestamp, readerror)
|
||||
except KeyError:
|
||||
pass # ignore updates of unknown parameters
|
||||
if action in (EVENTREPLY, ERRORPREFIX + EVENTREPLY):
|
||||
continue
|
||||
try:
|
||||
key = action, ident
|
||||
entry = self.active_requests.pop(key)
|
||||
except KeyError:
|
||||
if action.startswith(ERRORPREFIX):
|
||||
try:
|
||||
key = REQUEST2REPLY[action[len(ERRORPREFIX):]], ident
|
||||
except KeyError:
|
||||
key = None
|
||||
entry = self.active_requests.pop(key, None)
|
||||
else:
|
||||
# this may be a response to the last unknown request
|
||||
key = None
|
||||
entry = self.active_requests.pop(key, None)
|
||||
if entry is None:
|
||||
self._unhandled_message(action, ident, data)
|
||||
continue
|
||||
entry[2] = action, ident, data
|
||||
entry[1].set() # trigger event
|
||||
while not self.pending.empty():
|
||||
# let the TX thread sort out which entry to treat
|
||||
# this may have bad performance, but happens rarely
|
||||
self.txq.put(self.pending.get())
|
||||
except ConnectionClosed:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.log.error('rxthread ended with %r', e)
|
||||
self._rxthread = None
|
||||
self.disconnect(False)
|
||||
if self._shutdown:
|
||||
return
|
||||
if self.activate:
|
||||
self.log.info('try to reconnect to %s', self.uri)
|
||||
self._connthread = mkthread(self._reconnect)
|
||||
else:
|
||||
self.log.warning('%s disconnected', self.uri)
|
||||
self._set_state(False, 'disconnected')
|
||||
|
||||
def spawn_connect(self, connected_callback=None):
|
||||
"""try to connect in background
|
||||
|
||||
and trigger event when done and event is not None
|
||||
"""
|
||||
self.disconnect_time = time.time()
|
||||
self._connthread = mkthread(self._reconnect, connected_callback)
|
||||
|
||||
def _reconnect(self, connected_callback=None):
|
||||
while not self._shutdown:
|
||||
try:
|
||||
self.connect()
|
||||
if connected_callback:
|
||||
connected_callback()
|
||||
break
|
||||
except Exception as e:
|
||||
txt = str(e).split('\n', 1)[0]
|
||||
if txt != self._last_error:
|
||||
self._last_error = txt
|
||||
if 'join' in str(e):
|
||||
raise
|
||||
self.log.error(str(e))
|
||||
if time.time() > self.disconnect_time + self.reconnect_timeout:
|
||||
if self.online: # was recently connected
|
||||
self.disconnect_time = 0
|
||||
self.log.warning('can not reconnect to %s (%r)' % (self.nodename, e))
|
||||
self.log.info('continue trying to reconnect')
|
||||
# self.log.warning(formatExtendedTraceback())
|
||||
self._set_state(False)
|
||||
time.sleep(self.reconnect_timeout)
|
||||
else:
|
||||
time.sleep(1)
|
||||
self._connthread = None
|
||||
|
||||
def disconnect(self, shutdown=True):
|
||||
self._running = False
|
||||
if shutdown:
|
||||
self._shutdown = True
|
||||
self._set_state(False, 'shutdown')
|
||||
if self._connthread:
|
||||
if self._connthread == current_thread():
|
||||
return
|
||||
# wait for connection thread stopped
|
||||
self._connthread.join()
|
||||
self._connthread = None
|
||||
self.disconnect_time = time.time()
|
||||
try: # make sure txq does not block
|
||||
while not self.txq.empty():
|
||||
self.txq.get(False)
|
||||
except Exception:
|
||||
pass
|
||||
if self._txthread:
|
||||
self.txq.put(None) # shutdown marker
|
||||
self._txthread.join()
|
||||
self._txthread = None
|
||||
if self._rxthread:
|
||||
self._rxthread.join()
|
||||
self._rxthread = None
|
||||
if self.io:
|
||||
self.io.disconnect()
|
||||
self.io = None
|
||||
# abort pending requests early
|
||||
try: # avoid race condition
|
||||
while self.active_requests:
|
||||
_, (_, event, _) = self.active_requests.popitem()
|
||||
event.set()
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
while True:
|
||||
_, event, _ = self.pending.get(block=False)
|
||||
event.set()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def _init_descriptive_data(self, data):
|
||||
"""rebuild descriptive data"""
|
||||
changed_modules = None
|
||||
if json.dumps(data, sort_keys=True) != json.dumps(self.descriptive_data, sort_keys=True):
|
||||
if self.descriptive_data:
|
||||
changed_modules = set()
|
||||
modules = data.get('modules', {})
|
||||
for modname, moddesc in self.descriptive_data['modules'].items():
|
||||
if json.dumps(moddesc, sort_keys=True) != json.dumps(modules.get(modname), sort_keys=True):
|
||||
changed_modules.add(modname)
|
||||
self.descriptive_data = data
|
||||
modules = data['modules']
|
||||
self.modules = {}
|
||||
self.properties = {k: v for k, v in data.items() if k != 'modules'}
|
||||
self.identifier = {} # map (module, parameter) -> identifier
|
||||
self.internal = {} # map identifier -> (module, parameter)
|
||||
for modname, moddescr in modules.items():
|
||||
# separate accessibles into command and parameters
|
||||
parameters = {}
|
||||
commands = {}
|
||||
accessibles = moddescr['accessibles']
|
||||
for aname, aentry in accessibles.items():
|
||||
iname = self.internalize_name(aname)
|
||||
datatype = get_datatype(aentry['datainfo'], iname)
|
||||
aentry = dict(aentry, datatype=datatype)
|
||||
ident = '%s:%s' % (modname, aname)
|
||||
self.identifier[modname, iname] = ident
|
||||
self.internal[ident] = modname, iname
|
||||
if datatype.IS_COMMAND:
|
||||
commands[iname] = aentry
|
||||
else:
|
||||
parameters[iname] = aentry
|
||||
properties = {k: v for k, v in moddescr.items() if k != 'accessibles'}
|
||||
self.modules[modname] = dict(accessibles=accessibles, parameters=parameters,
|
||||
commands=commands, properties=properties)
|
||||
if changed_modules is not None:
|
||||
done = done_main = self.callback(None, 'descriptiveDataChange', None, self)
|
||||
for mname in changed_modules:
|
||||
if not self.callback(mname, 'descriptiveDataChange', mname, self):
|
||||
if not done_main:
|
||||
self.log.warning('descriptive data changed on module %r', mname)
|
||||
done = True
|
||||
if not done:
|
||||
self.log.warning('descriptive data of %r changed', self.nodename)
|
||||
|
||||
def _unhandled_message(self, action, ident, data):
|
||||
if not self.callback(None, 'unhandledMessage', action, ident, data):
|
||||
self.log.warning('unhandled message: %s %s %r', action, ident, data)
|
||||
|
||||
def _set_state(self, online, state=None):
|
||||
# remark: reconnecting is treated as online
|
||||
self.online = online
|
||||
self.state = state or self.state
|
||||
self.callback(None, 'nodeStateChange', self.online, self.state)
|
||||
for mname in self.modules:
|
||||
self.callback(mname, 'nodeStateChange', self.online, self.state)
|
||||
|
||||
def queue_request(self, action, ident=None, data=None):
|
||||
"""make a request"""
|
||||
request = action, ident, data
|
||||
self.connect() # make sure we are connected
|
||||
# the last item is for the reply
|
||||
entry = [request, Event(), None]
|
||||
self.txq.put(entry, timeout=3)
|
||||
return entry
|
||||
|
||||
def get_reply(self, entry):
|
||||
"""wait for reply and return it"""
|
||||
if not entry[1].wait(10): # event
|
||||
raise TimeoutError('no response within 10s')
|
||||
if not entry[2]: # reply
|
||||
raise ConnectionError('connection closed before reply')
|
||||
action, _, data = entry[2] # pylint: disable=unpacking-non-sequence
|
||||
if action.startswith(ERRORPREFIX):
|
||||
errcls = self.error_map(data[0])
|
||||
raise errcls(data[1])
|
||||
return entry[2] # reply
|
||||
|
||||
def request(self, action, ident=None, data=None):
|
||||
"""make a request
|
||||
|
||||
and wait for reply
|
||||
"""
|
||||
entry = self.queue_request(action, ident, data)
|
||||
return self.get_reply(entry)
|
||||
|
||||
def readParameter(self, module, parameter):
|
||||
"""forced read over connection"""
|
||||
try:
|
||||
self.request(READREQUEST, self.identifier[module, parameter])
|
||||
except frappy.errors.SECoPError:
|
||||
# error reply message is already stored as readerror in cache
|
||||
pass
|
||||
return self.cache.get((module, parameter), None)
|
||||
|
||||
def getParameter(self, module, parameter, trycache=False):
|
||||
if trycache:
|
||||
cached = self.cache.get((module, parameter), None)
|
||||
if cached:
|
||||
return cached
|
||||
if self.online:
|
||||
self.readParameter(module, parameter)
|
||||
return self.cache[module, parameter]
|
||||
|
||||
def setParameter(self, module, parameter, value):
|
||||
self.connect() # make sure we are connected
|
||||
datatype = self.modules[module]['parameters'][parameter]['datatype']
|
||||
value = datatype.export_value(value)
|
||||
self.request(WRITEREQUEST, self.identifier[module, parameter], value)
|
||||
return self.cache[module, parameter]
|
||||
|
||||
def execCommand(self, module, command, argument=None):
|
||||
self.connect() # make sure we are connected
|
||||
datatype = self.modules[module]['commands'][command]['datatype'].argument
|
||||
if datatype:
|
||||
argument = datatype.export_value(argument)
|
||||
else:
|
||||
if argument is not None:
|
||||
raise frappy.errors.BadValueError('command has no argument')
|
||||
# pylint: disable=unsubscriptable-object
|
||||
data, qualifiers = self.request(COMMANDREQUEST, self.identifier[module, command], argument)[2]
|
||||
datatype = self.modules[module]['commands'][command]['datatype'].result
|
||||
if datatype:
|
||||
data = datatype.import_value(data)
|
||||
return data, qualifiers
|
||||
|
||||
# the following attributes may be/are intended to be overwritten by a subclass
|
||||
|
||||
ERROR_MAP = frappy.errors.EXCEPTIONS
|
||||
DEFAULT_EXCEPTION = frappy.errors.SECoPError
|
||||
PREDEFINED_NAMES = set(frappy.params.PREDEFINED_ACCESSIBLES)
|
||||
activate = True
|
||||
|
||||
def error_map(self, exc):
|
||||
"""how to convert SECoP and unknown exceptions"""
|
||||
return self.ERROR_MAP.get(exc, self.DEFAULT_EXCEPTION)
|
||||
|
||||
def internalize_name(self, name):
|
||||
"""how to create internal names"""
|
||||
if name.startswith('_') and name[1:] not in self.PREDEFINED_NAMES:
|
||||
return name[1:]
|
||||
return name
|
||||
289
frappy/client/interactive.py
Normal file
@@ -0,0 +1,289 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""simple interactive python client"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import re
|
||||
from queue import Queue
|
||||
from frappy.client import SecopClient
|
||||
from frappy.errors import SECoPError
|
||||
from frappy.datatypes import get_datatype
|
||||
|
||||
USAGE = """
|
||||
Usage:
|
||||
|
||||
from frappy.client.interactive import Client
|
||||
|
||||
client = Client('localhost:5000') # start client.
|
||||
# this connects and creates objects for all SECoP modules in the main namespace
|
||||
|
||||
<module> # list all parameters
|
||||
<module>.<param> = <value> # change parameter
|
||||
<module>(<target>) # set target and wait until not busy
|
||||
# 'status' and 'value' changes are shown every 1 sec
|
||||
client.mininterval = 0.2 # change minimal update interval to 0.2 sec (default is 1 second)
|
||||
|
||||
<module>.watch(1) # watch changes of all parameters of a module
|
||||
<module>.watch(0) # remove all watching
|
||||
<module>.watch(status=1, value=1) # add 'status' and 'value' to watched parameters
|
||||
<module>.watch(value=0) # remove 'value' from watched parameters
|
||||
"""
|
||||
|
||||
main = sys.modules['__main__']
|
||||
|
||||
|
||||
class Logger:
|
||||
def __init__(self, loglevel='info'):
|
||||
func = self.noop
|
||||
for lev in 'debug', 'info', 'warning', 'error':
|
||||
if lev == loglevel:
|
||||
func = self.emit
|
||||
setattr(self, lev, func)
|
||||
self._minute = 0
|
||||
|
||||
def emit(self, fmt, *args, **kwds):
|
||||
now = time.time()
|
||||
minute = now // 60
|
||||
if minute != self._minute:
|
||||
self._minute = minute
|
||||
print(time.strftime('--- %H:%M:%S ---', time.localtime(now)))
|
||||
print('%6.3f' % (now % 60.0), str(fmt) % args)
|
||||
|
||||
@staticmethod
|
||||
def noop(fmt, *args, **kwds):
|
||||
pass
|
||||
|
||||
|
||||
class PrettyFloat(float):
|
||||
def __repr__(self):
|
||||
result = '%.12g' % self
|
||||
if '.' in result or 'e' in result:
|
||||
return result
|
||||
return result + '.'
|
||||
|
||||
|
||||
class Module:
|
||||
_log_pattern = re.compile('.*')
|
||||
|
||||
def __init__(self, name, secnode):
|
||||
self._name = name
|
||||
self._secnode = secnode
|
||||
self._parameters = list(secnode.modules[name]['parameters'])
|
||||
self._commands = list(secnode.modules[name]['commands'])
|
||||
self._running = None
|
||||
self._status = None
|
||||
props = secnode.modules[name]['properties']
|
||||
self._title = '# %s (%s)' % (props.get('implementation', ''), props.get('interface_classes', [''])[0])
|
||||
|
||||
def _one_line(self, pname, minwid=0):
|
||||
"""return <module>.<param> = <value> truncated to one line"""
|
||||
param = getattr(type(self), pname)
|
||||
try:
|
||||
value = getattr(self, pname)
|
||||
r = param.format(value)
|
||||
except Exception as e:
|
||||
r = repr(e)
|
||||
pname = pname.ljust(minwid)
|
||||
vallen = 113 - len(self._name) - len(pname)
|
||||
if len(r) > vallen:
|
||||
r = r[:vallen - 4] + ' ...'
|
||||
return '%s.%s = %s' % (self._name, pname, r)
|
||||
|
||||
def _isBusy(self):
|
||||
return 300 <= self.status[0] < 400
|
||||
|
||||
def _status_value_update(self, m, p, status, t, e):
|
||||
if self._running:
|
||||
try:
|
||||
self._running.put(True)
|
||||
if self._running and not self._isBusy():
|
||||
self._running.put(False)
|
||||
except TypeError: # may happen when _running is removed during above lines
|
||||
pass
|
||||
|
||||
def _watch_parameter(self, m, pname, *args, forced=False, mininterval=0):
|
||||
"""show parameter update"""
|
||||
pobj = getattr(type(self), pname)
|
||||
if not args:
|
||||
args = self._secnode.cache[self._name, pname]
|
||||
value = args[0]
|
||||
now = time.time()
|
||||
if (value != pobj.prev and now >= pobj.prev_time + mininterval) or forced:
|
||||
self._secnode.log.info('%s', self._one_line(pname))
|
||||
pobj.prev = value
|
||||
pobj.prev_time = now
|
||||
|
||||
def watch(self, *args, **kwds):
|
||||
enabled = {}
|
||||
for arg in args:
|
||||
if arg == 1: # or True
|
||||
enabled.update({k: True for k in self._parameters})
|
||||
elif arg == 0: # or False
|
||||
enabled.update({k: False for k in self._parameters})
|
||||
else:
|
||||
enabled.update(arg)
|
||||
enabled.update(kwds)
|
||||
for pname, enable in enabled.items():
|
||||
self._secnode.unregister_callback((self._name, pname), updateEvent=self._watch_parameter)
|
||||
if enable:
|
||||
self._secnode.register_callback((self._name, pname), updateEvent=self._watch_parameter)
|
||||
|
||||
def read(self, pname='value'):
|
||||
value, _, error = self._secnode.readParameter(self._name, pname)
|
||||
if error:
|
||||
raise error
|
||||
return value
|
||||
|
||||
def __call__(self, target=None):
|
||||
if target is None:
|
||||
return self.read()
|
||||
self.target = target # this sets self._running
|
||||
type(self).value.prev = None # show at least one value
|
||||
show_final_value = True
|
||||
try:
|
||||
while self._running.get():
|
||||
self._watch_parameter(self._name, 'value', mininterval=self._secnode.mininterval)
|
||||
self._watch_parameter(self._name, 'status')
|
||||
except KeyboardInterrupt:
|
||||
self._secnode.log.info('-- interrupted --')
|
||||
self._running = None
|
||||
self._watch_parameter(self._name, 'status')
|
||||
self._secnode.readParameter(self._name, 'value')
|
||||
self._watch_parameter(self._name, 'value', forced=show_final_value)
|
||||
return self.value
|
||||
|
||||
def __repr__(self):
|
||||
wid = max(len(k) for k in self._parameters)
|
||||
return '%s\n%s\nCommands: %s' % (
|
||||
self._title,
|
||||
'\n'.join(self._one_line(k, wid) for k in self._parameters),
|
||||
', '.join(k + '()' for k in self._commands))
|
||||
|
||||
def logging(self, level='comlog', pattern='.*'):
|
||||
self._log_pattern = re.compile(pattern)
|
||||
self._secnode.request('logging', self._name, level)
|
||||
|
||||
def handle_log_message_(self, data):
|
||||
if self._log_pattern.match(data):
|
||||
self._secnode.log.info('%s: %r', self._name, data)
|
||||
|
||||
|
||||
class Param:
|
||||
def __init__(self, name, datainfo):
|
||||
self.name = name
|
||||
self.prev = None
|
||||
self.prev_time = 0
|
||||
self.datatype = get_datatype(datainfo)
|
||||
|
||||
def __get__(self, obj, owner):
|
||||
if obj is None:
|
||||
return self
|
||||
value, _, error = obj._secnode.cache[obj._name, self.name]
|
||||
if error:
|
||||
raise error
|
||||
return value
|
||||
|
||||
def __set__(self, obj, value):
|
||||
if self.name == 'target':
|
||||
obj._running = Queue()
|
||||
try:
|
||||
obj._secnode.setParameter(obj._name, self.name, value)
|
||||
except SECoPError as e:
|
||||
obj._secnode.log.error(repr(e))
|
||||
|
||||
def format(self, value):
|
||||
return self.datatype.format_value(value)
|
||||
|
||||
|
||||
class Command:
|
||||
def __init__(self, name, modname, secnode):
|
||||
self.name = name
|
||||
self.modname = modname
|
||||
self.exec = secnode.execCommand
|
||||
|
||||
def call(self, *args, **kwds):
|
||||
if kwds:
|
||||
if args:
|
||||
raise TypeError('mixed arguments forbidden')
|
||||
result, _ = self.exec(self.modname, self.name, kwds)
|
||||
else:
|
||||
result, _ = self.exec(self.modname, self.name, args or None)
|
||||
return result
|
||||
|
||||
def __get__(self, obj, owner=None):
|
||||
if obj is None:
|
||||
return self
|
||||
return self.call
|
||||
|
||||
|
||||
class Client(SecopClient):
|
||||
activate = True
|
||||
secnodes = {}
|
||||
mininterval = 1
|
||||
|
||||
def __init__(self, uri, loglevel='info'):
|
||||
# remove previous client:
|
||||
prev = self.secnodes.pop(uri, None)
|
||||
if prev:
|
||||
prev.log.info('remove previous client to %s', uri)
|
||||
for modname in prev.modules:
|
||||
prevnode = getattr(getattr(main, modname, None), 'secnode', None)
|
||||
if prevnode == prev:
|
||||
prev.log.info('remove previous module %s', modname)
|
||||
delattr(main, modname)
|
||||
prev.disconnect()
|
||||
self.secnodes[uri] = self
|
||||
super().__init__(uri, Logger(loglevel))
|
||||
self.connect()
|
||||
for modname, moddesc in self.modules.items():
|
||||
prev = getattr(main, modname, None)
|
||||
if prev is None:
|
||||
self.log.info('create module %s', modname)
|
||||
else:
|
||||
if getattr(prev, 'secnode', None) is None:
|
||||
self.log.error('skip module %s overwriting a global variable' % modname)
|
||||
continue
|
||||
self.log.info('overwrite module %s', modname)
|
||||
attrs = {}
|
||||
for pname, pinfo in moddesc['parameters'].items():
|
||||
attrs[pname] = Param(pname, pinfo['datainfo'])
|
||||
for cname in moddesc['commands']:
|
||||
attrs[cname] = Command(cname, modname, self)
|
||||
mobj = type('M_%s' % modname, (Module,), attrs)(modname, self)
|
||||
if 'status' in mobj._parameters:
|
||||
self.register_callback((modname, 'status'), updateEvent=mobj._status_value_update)
|
||||
self.register_callback((modname, 'value'), updateEvent=mobj._status_value_update)
|
||||
setattr(main, modname, mobj)
|
||||
self.register_callback(None, self.unhandledMessage)
|
||||
self.log.info('%s', USAGE)
|
||||
|
||||
def unhandledMessage(self, action, ident, data):
|
||||
"""handle logging messages"""
|
||||
if action == 'log':
|
||||
modname = ident.split(':')[0]
|
||||
modobj = getattr(main, modname, None)
|
||||
if modobj:
|
||||
modobj.handle_log_message_(data)
|
||||
return
|
||||
self.log.info('module %s not found', modname)
|
||||
self.log.info('unhandled: %s %s %r', action, ident, data)
|
||||
45
frappy/core.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- 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 <alexander.lenz@frm2.tum.de>
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
# allow to import the most important classes from 'frappy'
|
||||
|
||||
# pylint: disable=unused-import
|
||||
from frappy.datatypes import ArrayOf, BLOBType, BoolType, EnumType, \
|
||||
FloatRange, IntRange, ScaledInteger, StringType, StructOf, TupleOf
|
||||
from frappy.lib.enum import Enum
|
||||
from frappy.modules import Attached, Communicator, \
|
||||
Done, Drivable, Feature, Module, Readable, Writable, HasAccessibles
|
||||
from frappy.params import Command, Parameter
|
||||
from frappy.properties import Property
|
||||
from frappy.proxy import Proxy, SecNode, proxy_class
|
||||
from frappy.io import HasIO, StringIO, BytesIO, HasIodev # TODO: remove HasIodev (legacy stuff)
|
||||
from frappy.persistent import PersistentMixin, PersistentParam
|
||||
from frappy.rwhandler import ReadHandler, WriteHandler, CommonReadHandler, \
|
||||
CommonWriteHandler, nopoll
|
||||
|
||||
ERROR = Drivable.Status.ERROR
|
||||
WARN = Drivable.Status.WARN
|
||||
BUSY = Drivable.Status.BUSY
|
||||
IDLE = Drivable.Status.IDLE
|
||||
1225
frappy/datatypes.py
Normal file
155
frappy/errors.py
Normal file
@@ -0,0 +1,155 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define (internal) SECoP Errors"""
|
||||
|
||||
|
||||
class SECoPError(RuntimeError):
|
||||
silent = False # silent = True indicates that the error is already logged
|
||||
|
||||
def __init__(self, *args, **kwds):
|
||||
super().__init__()
|
||||
self.args = args
|
||||
for k, v in list(kwds.items()):
|
||||
setattr(self, k, v)
|
||||
|
||||
def __repr__(self):
|
||||
args = ', '.join(map(repr, self.args))
|
||||
kwds = ', '.join(['%s=%r' % i for i in list(self.__dict__.items())
|
||||
if i[0] != 'silent'])
|
||||
res = []
|
||||
if args:
|
||||
res.append(args)
|
||||
if kwds:
|
||||
res.append(kwds)
|
||||
return '%s(%s)' % (self.name, ', '.join(res))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.__class__.__name__[:-len('Error')]
|
||||
|
||||
|
||||
class SECoPServerError(SECoPError):
|
||||
name = 'InternalError'
|
||||
|
||||
|
||||
class InternalError(SECoPError):
|
||||
name = 'InternalError'
|
||||
|
||||
|
||||
class ProgrammingError(SECoPError):
|
||||
name = 'InternalError'
|
||||
|
||||
|
||||
class ConfigError(SECoPError):
|
||||
name = 'InternalError'
|
||||
|
||||
|
||||
class ProtocolError(SECoPError):
|
||||
name = 'ProtocolError'
|
||||
|
||||
|
||||
class NoSuchModuleError(SECoPError):
|
||||
name = 'NoSuchModule'
|
||||
|
||||
|
||||
# pylint: disable=redefined-builtin
|
||||
class NotImplementedError(NotImplementedError, SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchParameterError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchCommandError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class ReadOnlyError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class BadValueError(ValueError, SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class CommandFailedError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class CommandRunningError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class CommunicationFailedError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class IsBusyError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class IsErrorError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class DisabledError(SECoPError):
|
||||
pass
|
||||
|
||||
|
||||
class HardwareError(SECoPError):
|
||||
name = 'HardwareError'
|
||||
|
||||
|
||||
def make_secop_error(name, text):
|
||||
errcls = EXCEPTIONS.get(name, InternalError)
|
||||
return errcls(text)
|
||||
|
||||
|
||||
def secop_error(exception):
|
||||
if isinstance(exception, SECoPError):
|
||||
return exception
|
||||
return InternalError(repr(exception))
|
||||
|
||||
|
||||
EXCEPTIONS = dict(
|
||||
NoSuchModule=NoSuchModuleError,
|
||||
NoSuchParameter=NoSuchParameterError,
|
||||
NoSuchCommand=NoSuchCommandError,
|
||||
CommandFailed=CommandFailedError,
|
||||
CommandRunning=CommandRunningError,
|
||||
ReadOnly=ReadOnlyError,
|
||||
BadValue=BadValueError,
|
||||
CommunicationFailed=CommunicationFailedError,
|
||||
HardwareError=HardwareError,
|
||||
IsBusy=IsBusyError,
|
||||
IsError=IsErrorError,
|
||||
Disabled=DisabledError,
|
||||
SyntaxError=ProtocolError,
|
||||
NotImplemented=NotImplementedError,
|
||||
ProtocolError=ProtocolError,
|
||||
InternalError=InternalError,
|
||||
# internal short versions (candidates for spec)
|
||||
Protocol=ProtocolError,
|
||||
Internal=InternalError,
|
||||
)
|
||||
245
frappy/features.py
Normal file
@@ -0,0 +1,245 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define Mixin Features for real Modules implemented in the server"""
|
||||
|
||||
|
||||
from frappy.datatypes import ArrayOf, BoolType, EnumType, \
|
||||
FloatRange, StringType, StructOf, TupleOf
|
||||
from frappy.core import Command, Done, Drivable, Feature, \
|
||||
Parameter, Property, PersistentParam, Readable
|
||||
from frappy.errors import BadValueError, ConfigError
|
||||
from frappy.lib import clamp
|
||||
|
||||
|
||||
# --- proposals, to be used at SINQ (not agreed as standard yet) ---
|
||||
|
||||
class HasOffset(Feature):
|
||||
"""has an offset parameter
|
||||
|
||||
implementation to be done in the subclass
|
||||
"""
|
||||
offset = PersistentParam('offset (physical value + offset = HW value)',
|
||||
FloatRange(unit='deg'), readonly=False, default=0)
|
||||
|
||||
def write_offset(self, value):
|
||||
self.offset = value
|
||||
if isinstance(self, HasLimits):
|
||||
self.read_limits()
|
||||
if isinstance(self, Readable):
|
||||
self.read_value()
|
||||
if isinstance(self, Drivable):
|
||||
self.read_target()
|
||||
self.saveParameters()
|
||||
return Done
|
||||
|
||||
|
||||
class HasLimits(Feature):
|
||||
"""user limits
|
||||
|
||||
implementation to be done in the subclass
|
||||
|
||||
for a drivable, abslimits is roughly the same as the target datatype limits,
|
||||
except for the offset
|
||||
"""
|
||||
abslimits = Property('abs limits (raw values)', default=(-9e99, 9e99), extname='abslimits', export=True,
|
||||
datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg')))
|
||||
limits = PersistentParam('user limits', readonly=False, default=(-9e99, 9e99), initwrite=True,
|
||||
datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg')))
|
||||
_limits = None
|
||||
|
||||
def apply_offset(self, sign, *values):
|
||||
if isinstance(self, HasOffset):
|
||||
return tuple(v + sign * self.offset for v in values)
|
||||
return values
|
||||
|
||||
def earlyInit(self):
|
||||
super().earlyInit()
|
||||
# make limits valid
|
||||
_limits = self.apply_offset(1, *self.limits)
|
||||
self._limits = tuple(clamp(self.abslimits[0], v, self.abslimits[1]) for v in _limits)
|
||||
self.read_limits()
|
||||
|
||||
def checkProperties(self):
|
||||
pname = 'target' if isinstance(self, Drivable) else 'value'
|
||||
dt = self.parameters[pname].datatype
|
||||
min_, max_ = self.abslimits
|
||||
t_min, t_max = self.apply_offset(1, dt.min, dt.max)
|
||||
if t_min > max_ or t_max < min_:
|
||||
raise ConfigError('abslimits not within %s range' % pname)
|
||||
self.abslimits = clamp(t_min, min_, t_max), clamp(t_min, max_, t_max)
|
||||
super().checkProperties()
|
||||
|
||||
def read_limits(self):
|
||||
return self.apply_offset(-1, *self._limits)
|
||||
|
||||
def write_limits(self, value):
|
||||
min_, max_ = self.apply_offset(-1, *self.abslimits)
|
||||
if not min_ <= value[0] <= value[1] <= max_:
|
||||
if value[0] > value[1]:
|
||||
raise BadValueError('invalid interval: %r' % value)
|
||||
raise BadValueError('limits not within abs limits [%g, %g]' % (min_, max_))
|
||||
self.limits = value
|
||||
self.saveParameters()
|
||||
return Done
|
||||
|
||||
def check_limits(self, value):
|
||||
"""check if value is valid"""
|
||||
min_, max_ = self.limits
|
||||
if not min_ <= value <= max_:
|
||||
raise BadValueError('limits violation: %g outside [%g, %g]' % (value, min_, max_))
|
||||
|
||||
|
||||
# --- not used, not tested yet ---
|
||||
|
||||
class HAS_PID(Feature):
|
||||
# note: implementors should either use p,i,d or pid, but ECS must be handle both cases
|
||||
# note: if both p,i,d and pid are implemented, it MUST NOT matter which one gets a change, the final result should be the same
|
||||
# note: if there are additional custom accessibles with the same name as an element of the struct, the above applies
|
||||
# note: (i would still but them in the same group, though)
|
||||
# note: if extra elements are implemented in the pid struct they MUST BE
|
||||
# properly described in the description of the pid Parameter
|
||||
|
||||
# parameters
|
||||
use_pid = Parameter('use the pid mode', datatype=EnumType(openloop=0, pid_control=1), )
|
||||
# pylint: disable=invalid-name
|
||||
p = Parameter('proportional part of the regulation', datatype=FloatRange(0), )
|
||||
i = Parameter('(optional) integral part', datatype=FloatRange(0), optional=True)
|
||||
d = Parameter('(optional) derivative part', datatype=FloatRange(0), optional=True)
|
||||
base_output = Parameter('(optional) minimum output value', datatype=FloatRange(0), optional=True)
|
||||
pid = Parameter('(optional) Struct of p,i,d, minimum output value',
|
||||
datatype=StructOf(p=FloatRange(0),
|
||||
i=FloatRange(0),
|
||||
d=FloatRange(0),
|
||||
base_output=FloatRange(0),
|
||||
), optional=True,
|
||||
) # note: struct may be extended with custom elements (names should be prefixed with '_')
|
||||
output = Parameter('(optional) output of pid-control', datatype=FloatRange(0), optional=True, readonly=False)
|
||||
|
||||
|
||||
class Has_PIDTable(HAS_PID):
|
||||
|
||||
# parameters
|
||||
use_pidtable = Parameter('use the zoning mode', datatype=EnumType(fixed_pid=0, zone_mode=1))
|
||||
pidtable = Parameter('Table of pid-values vs. target temperature', datatype=ArrayOf(TupleOf(FloatRange(0),
|
||||
StructOf(p=FloatRange(0),
|
||||
i=FloatRange(0),
|
||||
d=FloatRange(0),
|
||||
_heater_range=FloatRange(0),
|
||||
_base_output=FloatRange(0),),),), optional=True) # struct may include 'heaterrange'
|
||||
|
||||
|
||||
class HAS_Persistent(Feature):
|
||||
#extra_Status {
|
||||
# 'decoupled' : Status.IDLE+1, # to be discussed.
|
||||
# 'coupling' : Status.BUSY+1, # to be discussed.
|
||||
# 'coupled' : Status.BUSY+2, # to be discussed.
|
||||
# 'decoupling' : Status.BUSY+3, # to be discussed.
|
||||
#}
|
||||
|
||||
# parameters
|
||||
persistent_mode = Parameter('Use persistent mode',
|
||||
datatype=EnumType(off=0,on=1),
|
||||
default=0, readonly=False)
|
||||
is_persistent = Parameter('current state of persistence',
|
||||
datatype=BoolType(), optional=True)
|
||||
# stored_value = Parameter('current persistence value, often used as the modules value',
|
||||
# datatype='main', unit='$', optional=True)
|
||||
# driven_value = Parameter('driven value (outside value, syncs with stored_value if non-persistent)',
|
||||
# datatype='main', unit='$' )
|
||||
|
||||
|
||||
class HAS_Tolerance(Feature):
|
||||
# detects IDLE status by checking if the value lies in a given window:
|
||||
# tolerance is the maximum allowed deviation from target, value must lie in this interval
|
||||
# for at least ´timewindow´ seconds.
|
||||
|
||||
# parameters
|
||||
tolerance = Parameter('Half height of the Window',
|
||||
datatype=FloatRange(0), default=1, unit='$')
|
||||
timewindow = Parameter('Length of the timewindow to check',
|
||||
datatype=FloatRange(0), default=30, unit='s',
|
||||
optional=True)
|
||||
|
||||
|
||||
class HAS_Timeout(Feature):
|
||||
|
||||
# parameters
|
||||
timeout = Parameter('timeout for movement',
|
||||
datatype=FloatRange(0), default=0, unit='s')
|
||||
|
||||
|
||||
class HAS_Pause(Feature):
|
||||
# just a proposal, can't agree on it....
|
||||
|
||||
@Command(argument=None, result=None)
|
||||
def pause(self):
|
||||
"""pauses movement"""
|
||||
|
||||
@Command(argument=None, result=None)
|
||||
def go(self):
|
||||
"""continues movement or start a new one if target was change since the last pause"""
|
||||
|
||||
|
||||
class HAS_Ramp(Feature):
|
||||
|
||||
# parameters
|
||||
ramp =Parameter('speed of movement', unit='$/min',
|
||||
datatype=FloatRange(0))
|
||||
use_ramp = Parameter('use the ramping of the setpoint, or jump',
|
||||
datatype=EnumType(disable_ramp=0, use_ramp=1),
|
||||
optional=True)
|
||||
setpoint = Parameter('currently active setpoint',
|
||||
datatype=FloatRange(0), unit='$',
|
||||
readonly=True, )
|
||||
|
||||
|
||||
class HAS_Speed(Feature):
|
||||
|
||||
# parameters
|
||||
speed = Parameter('(maximum) speed of movement (of the main value)',
|
||||
unit='$/s', datatype=FloatRange(0))
|
||||
|
||||
|
||||
class HAS_Accel(HAS_Speed):
|
||||
|
||||
# parameters
|
||||
accel = Parameter('acceleration of movement', unit='$/s^2',
|
||||
datatype=FloatRange(0))
|
||||
decel = Parameter('deceleration of movement', unit='$/s^2',
|
||||
datatype=FloatRange(0), optional=True)
|
||||
|
||||
|
||||
class HAS_MotorCurrents(Feature):
|
||||
|
||||
# parameters
|
||||
movecurrent = Parameter('Current while moving',
|
||||
datatype=FloatRange(0))
|
||||
idlecurrent = Parameter('Current while idle',
|
||||
datatype=FloatRange(0), optional=True)
|
||||
|
||||
|
||||
class HAS_Curve(Feature):
|
||||
# proposed, not yet agreed upon!
|
||||
|
||||
# parameters
|
||||
curve = Parameter('Calibration curve', datatype=StringType(), default='<unset>')
|
||||
0
frappy/gui/__init__.py
Normal file
0
frappy/gui/cfg_editor/__init__.py
Normal file
275
frappy/gui/cfg_editor/config_file.py
Normal file
@@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Sandra Seger <sandra.seger@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
import configparser
|
||||
from collections import OrderedDict
|
||||
from configparser import NoOptionError
|
||||
|
||||
from frappy.gui.cfg_editor.tree_widget_item import TreeWidgetItem
|
||||
from frappy.gui.cfg_editor.utils import get_all_children_with_names, \
|
||||
get_all_items, get_interface_class_from_name, \
|
||||
get_module_class_from_name, get_params, get_props
|
||||
|
||||
NODE = 'node'
|
||||
INTERFACE = 'interface'
|
||||
MODULE = 'module'
|
||||
PARAMETER = 'parameter'
|
||||
PROPERTY = 'property'
|
||||
COMMENT = 'comment'
|
||||
|
||||
SECTIONS = {NODE: 'description',
|
||||
INTERFACE: 'type',
|
||||
MODULE: 'class'}
|
||||
|
||||
|
||||
def write_config(file_name, tree_widget):
|
||||
itms = get_all_items(tree_widget)
|
||||
itm_lines = OrderedDict()
|
||||
value_str = '%s = %s'
|
||||
blank_lines = 0
|
||||
for itm in itms:
|
||||
if itm.kind is None:
|
||||
continue
|
||||
par = itm.parent()
|
||||
value = str(itm.get_value())
|
||||
if itm.kind in SECTIONS:
|
||||
if itm.kind in [MODULE, INTERFACE]:
|
||||
itm_lines[blank_lines] = ''
|
||||
blank_lines += 1
|
||||
value = value.replace('\n\n', '\n.\n')
|
||||
value = value.replace('\n', '\n ')
|
||||
itm_lines[id(itm)] = '[%s %s]\n' % (itm.kind, itm.name) +\
|
||||
value_str % (SECTIONS[itm.kind], value)
|
||||
# TODO params and props
|
||||
elif itm.kind == PARAMETER and value:
|
||||
itm_lines[id(itm)] = value_str % (itm.name, value)
|
||||
elif itm.kind == PROPERTY:
|
||||
prop_name = '.%s' % itm.name
|
||||
if par.kind == PARAMETER:
|
||||
prop_name = par.name + prop_name
|
||||
itm_lines[id(itm)] = value_str % (prop_name, value)
|
||||
elif itm.kind == COMMENT:
|
||||
temp_itm_lines = OrderedDict()
|
||||
for key, dict_value in itm_lines.items():
|
||||
if key == id(par):
|
||||
value = value.replace('\n', '\n# ')
|
||||
temp_itm_lines[id(itm)] = '# %s' % value
|
||||
temp_itm_lines[key] = dict_value
|
||||
itm_lines.clear()
|
||||
itm_lines.update(temp_itm_lines)
|
||||
with open(file_name, 'w', encoding='utf-8') as configfile:
|
||||
configfile.write('\n'.join(itm_lines.values()))
|
||||
|
||||
|
||||
def read_config(file_path):
|
||||
# TODO datatype of params and properties
|
||||
node = TreeWidgetItem(NODE)
|
||||
ifs = TreeWidgetItem(name='interfaces')
|
||||
mods = TreeWidgetItem(name='modules')
|
||||
node.addChild(ifs)
|
||||
node.addChild(mods)
|
||||
config = configparser.ConfigParser()
|
||||
with open(file_path, encoding='utf-8') as config_file:
|
||||
config.read_file(config_file)
|
||||
|
||||
for section in config.sections():
|
||||
kind = section.split(' ', 1)[0]
|
||||
name = section.split(' ', 1)[1]
|
||||
try:
|
||||
section_value = get_value(config, section, SECTIONS[kind])
|
||||
section_value = section_value.replace('\n.\n', '\n\n') \
|
||||
if kind == NODE else section_value
|
||||
except NoOptionError:
|
||||
# TODO invalid configuration
|
||||
continue
|
||||
if kind == NODE:
|
||||
node.set_name(name)
|
||||
act_item = node
|
||||
act_item.set_value(section_value)
|
||||
act_item.parameters = get_params(kind)
|
||||
act_item.properties = get_props(kind)
|
||||
else:
|
||||
act_item = TreeWidgetItem(kind, name)
|
||||
act_item.set_value(section_value)
|
||||
if kind == MODULE:
|
||||
mods.addChild(act_item)
|
||||
act_class = get_module_class_from_name(section_value)
|
||||
act_item.set_class_object(act_class)
|
||||
else:
|
||||
act_class = get_interface_class_from_name(section_value)
|
||||
act_item.set_class_object(act_class)
|
||||
ifs.addChild(act_item)
|
||||
act_item.parameters = get_params(act_class)
|
||||
act_item.properties = get_props(act_class)
|
||||
|
||||
# TODO rewrite so Parameters and Properties get class_object and
|
||||
# properties, needed information in act_item.parameters/properties
|
||||
for option in config.options(section):
|
||||
if option != SECTIONS[kind]:
|
||||
if option[0] == '.':
|
||||
prop = TreeWidgetItem(PROPERTY, option[1:],
|
||||
get_value(config, section, option))
|
||||
act_item.addChild(prop)
|
||||
else:
|
||||
separated = option.split('.')
|
||||
act_children = get_all_children_with_names(act_item)
|
||||
# TODO find param / props in params, props and add datatype
|
||||
if separated[0] in act_children:
|
||||
param = act_children[separated[0]]
|
||||
else:
|
||||
param = TreeWidgetItem(PARAMETER, separated[0])
|
||||
act_item.addChild(param)
|
||||
if len(separated) == 1:
|
||||
param.set_value(get_value(config, section, option))
|
||||
else:
|
||||
param.addChild(TreeWidgetItem(PROPERTY,
|
||||
separated[1], get_value(config, section,
|
||||
option)))
|
||||
node = get_comments(node, ifs, mods, file_path)
|
||||
return node, ifs, mods
|
||||
|
||||
|
||||
def get_value(config, section, option):
|
||||
value = config.get(section, option)
|
||||
if value.find('#') != -1:
|
||||
value = value[:value.find('#')]
|
||||
return value
|
||||
|
||||
|
||||
def get_comments(node, ifs, mods, file_path):
|
||||
with open(file_path, 'r', encoding='utf-8') as configfile:
|
||||
all_lines = configfile.readlines()
|
||||
current_comment = None
|
||||
all_ifs = get_all_children_with_names(ifs)
|
||||
all_mods = get_all_children_with_names(mods)
|
||||
for index, line in enumerate(all_lines):
|
||||
line = line[:-1]
|
||||
if line.startswith('#'):
|
||||
line = line[1:].strip()
|
||||
if index and all_lines[index-1][0] == '#':
|
||||
current_comment.set_value('%s\n%s' % (current_comment.
|
||||
get_value(), line))
|
||||
else:
|
||||
current_comment = TreeWidgetItem(COMMENT, '#', line)
|
||||
next_line = get_next_line(index, all_lines)
|
||||
if not next_line:
|
||||
node.insertChild(0, current_comment)
|
||||
continue
|
||||
insert_comment(index, next_line, all_lines, current_comment,
|
||||
node, all_ifs, all_mods)
|
||||
elif line.find('#') != -1:
|
||||
comment_index = line.find('#')
|
||||
current_comment = TreeWidgetItem(COMMENT, '#',
|
||||
line[comment_index+1:].strip())
|
||||
line = line[:comment_index]
|
||||
insert_comment(index, line, all_lines, current_comment, node,
|
||||
all_ifs, all_mods)
|
||||
return node
|
||||
|
||||
|
||||
def insert_comment(index, line, all_lines, current_comment, node, all_ifs, all_mods):
|
||||
if not insert_section_comment(line, current_comment, node, all_ifs,
|
||||
all_mods):
|
||||
insert_param_prop_comment(index, line, all_lines, current_comment, node,
|
||||
all_ifs, all_mods)
|
||||
|
||||
|
||||
# pylint: disable=inconsistent-return-statements
|
||||
def insert_section_comment(line, current_comment, node, all_ifs, all_mods):
|
||||
try:
|
||||
if line.startswith('[%s' % NODE):
|
||||
node.insertChild(0, current_comment)
|
||||
elif line.startswith('[%s' % INTERFACE):
|
||||
all_ifs[get_name_of_section(line)]. \
|
||||
insertChild(0, current_comment)
|
||||
elif line.startswith('[%s' % MODULE):
|
||||
all_mods[get_name_of_section(line)]. \
|
||||
insertChild(0, current_comment)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except KeyError:
|
||||
# TODO invalid file
|
||||
pass
|
||||
|
||||
|
||||
def insert_param_prop_comment(index, line, all_lines, current_comment,
|
||||
node, all_ifs, all_mods):
|
||||
try:
|
||||
parent = get_previous_line(index, all_lines)
|
||||
if not parent:
|
||||
# TODO invalid file
|
||||
pass
|
||||
if parent.startswith('[%s' % NODE):
|
||||
parent_item = node
|
||||
elif parent.startswith('[%s' % INTERFACE):
|
||||
parent_item = all_ifs[get_name_of_section(parent)]
|
||||
else:
|
||||
parent_item = all_mods[get_name_of_section(parent)]
|
||||
parent_children = get_all_children_with_names(
|
||||
parent_item)
|
||||
line = line.replace(' ', '')
|
||||
line = line.split('=')[0]
|
||||
dot_i = line.find('.')
|
||||
if dot_i == -1:
|
||||
# parameter
|
||||
parent_children[line].insertChild(
|
||||
0, current_comment)
|
||||
elif dot_i == 0:
|
||||
# .property
|
||||
parent_children[line[1:]].insertChild(
|
||||
0, current_comment)
|
||||
else:
|
||||
# parameter.property
|
||||
sub_childs = get_all_children_with_names(
|
||||
parent_children[line[:dot_i]])
|
||||
sub_childs[line[dot_i + 1:]].insertChild(
|
||||
0, current_comment)
|
||||
except KeyError:
|
||||
# TODO invalid file
|
||||
pass
|
||||
|
||||
|
||||
def get_next_line(index, all_lines):
|
||||
next_index = index + 1
|
||||
try:
|
||||
while all_lines[next_index][0] == '#' or \
|
||||
all_lines[next_index][:-1].strip() == '':
|
||||
next_index += 1
|
||||
except IndexError:
|
||||
return ''
|
||||
return all_lines[next_index][:-1].strip()
|
||||
|
||||
|
||||
def get_previous_line(index, all_lines):
|
||||
prev_index = index - 1
|
||||
try:
|
||||
while all_lines[prev_index].strip()[0] != '[':
|
||||
prev_index -= 1
|
||||
except IndexError:
|
||||
return ''
|
||||
return all_lines[prev_index]
|
||||
|
||||
|
||||
def get_name_of_section(line):
|
||||
line = line[line.find('['):line.rfind(']')]
|
||||
return ' '.join(line.split(' ', 1)[1:])
|
||||
226
frappy/gui/cfg_editor/mainwindow.py
Normal file
@@ -0,0 +1,226 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Sandra Seger <sandra.seger@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
import os
|
||||
|
||||
from frappy.gui.cfg_editor.node_display import NodeDisplay
|
||||
from frappy.gui.cfg_editor.utils import get_file_paths, loadUi
|
||||
from frappy.gui.cfg_editor.widgets import TabBar
|
||||
from frappy.gui.qt import QMainWindow, QMessageBox
|
||||
|
||||
# TODO move frappy mainwindow to gui/client and all specific stuff
|
||||
NODE = 'node'
|
||||
MODULE = 'module'
|
||||
INTERFACE = 'interface'
|
||||
PARAMETER = 'parameter'
|
||||
PROPERTY = 'property'
|
||||
COMMENT = 'comment'
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
|
||||
def __init__(self, file_path=None, parent=None):
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'mainwindow.ui')
|
||||
self.tabWidget.currentChanged.connect(self.tab_relevant_btns_disable)
|
||||
if file_path is None:
|
||||
self.tab_relevant_btns_disable(-1)
|
||||
else:
|
||||
self.duplicate_btn.setEnabled(False)
|
||||
self.delete_btn.setEnabled(False)
|
||||
self.tabWidget.setTabBar(TabBar())
|
||||
self.tabWidget.tabBar().tabCloseRequested.connect(self.close_tab)
|
||||
self.open_file(file_path)
|
||||
self.new_files = 0
|
||||
|
||||
def on_actionNew(self):
|
||||
name = 'unnamed_%i.cfg' % self.new_files if self.new_files else \
|
||||
'unnamed.cfg'
|
||||
self.new_node(name)
|
||||
self.new_files += 1
|
||||
|
||||
def on_actionOpen(self):
|
||||
file_paths = get_file_paths(self)
|
||||
for file_path in file_paths:
|
||||
self.open_file(file_path)
|
||||
|
||||
def on_actionSave(self):
|
||||
self.save_tab(self.tabWidget.currentIndex())
|
||||
|
||||
def on_actionSave_as(self):
|
||||
self.save_tab(self.tabWidget.currentIndex(), True)
|
||||
|
||||
def on_action_Close(self):
|
||||
self.close_tab(self.tabWidget.currentIndex())
|
||||
|
||||
def on_actionQuit(self):
|
||||
self.close()
|
||||
|
||||
def on_actionAbout(self):
|
||||
QMessageBox.about(
|
||||
self, 'About cfg-editor',
|
||||
'''
|
||||
<h2>About cfg-editor</h2>
|
||||
<p style="font-style: italic">
|
||||
(C) 2019 MLZ instrument control
|
||||
</p>
|
||||
<p>
|
||||
cfg-editor is a graphical interface for editing
|
||||
FRAPPY-configuration-files.
|
||||
</p>
|
||||
<h3>Author:</h3>
|
||||
<ul>
|
||||
<li>Copyright (C) 2019
|
||||
<a href="mailto:sandra.seger@frm2.tum.de">Sandra Seger</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
cfg-editor is published under the
|
||||
<a href="http://www.gnu.org/licenses/gpl.html">GPL
|
||||
(GNU General Public License)</a>
|
||||
</p>
|
||||
''')
|
||||
|
||||
def on_add_module(self):
|
||||
self.tabWidget.currentWidget().tree_widget.add_module()
|
||||
|
||||
def on_add_interface(self):
|
||||
self.tabWidget.currentWidget().tree_widget.add_interface()
|
||||
|
||||
def on_add_parameter(self):
|
||||
self.tabWidget.currentWidget().tree_widget.add_parameter()
|
||||
|
||||
def on_add_property(self):
|
||||
self.tabWidget.currentWidget().tree_widget.add_property()
|
||||
|
||||
def on_add_comment(self):
|
||||
self.tabWidget.currentWidget().tree_widget.add_comment()
|
||||
|
||||
def on_duplicate(self):
|
||||
self.tabWidget.currentWidget().tree_widget.duplicate()
|
||||
|
||||
def on_delete(self):
|
||||
self.tabWidget.currentWidget().tree_widget.delete()
|
||||
|
||||
def open_file(self, file_path):
|
||||
for i in range(0, self.tabWidget.count()):
|
||||
if self.tabWidget.widget(i).tree_widget.file_path == file_path:
|
||||
self.tabWidget.setCurrentIndex(i)
|
||||
return
|
||||
if file_path:
|
||||
self.new_node(os.path.basename(file_path), file_path)
|
||||
|
||||
def close_tab(self, index):
|
||||
if self.tabWidget.widget(index).saved:
|
||||
reply = QMessageBox.Close
|
||||
else:
|
||||
reply = self.show_save_message(self.tabWidget.tabText(index))
|
||||
if reply == QMessageBox.Cancel:
|
||||
return
|
||||
if reply == QMessageBox.Save:
|
||||
self.save_tab(index)
|
||||
self.tabWidget.removeTab(index)
|
||||
|
||||
def save_tab(self, index, save_as=False):
|
||||
widget = self.tabWidget.widget(index)
|
||||
if widget.tree_widget.save(save_as):
|
||||
self.tabWidget.setTabText(index, os.path.basename(
|
||||
widget.tree_widget.file_path))
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.tabWidget.count():
|
||||
reply = None
|
||||
for i in range(0, self.tabWidget.count()):
|
||||
if not self.tabWidget.widget(i).saved:
|
||||
reply = self.show_save_message()
|
||||
break
|
||||
if not reply:
|
||||
reply = QMessageBox.Close
|
||||
if reply == QMessageBox.Cancel:
|
||||
event.ignore()
|
||||
return
|
||||
if reply == QMessageBox.Save:
|
||||
for i in range(0, self.tabWidget.count()):
|
||||
self.save_tab(i)
|
||||
event.accept()
|
||||
|
||||
def show_save_message(self, file_name=''):
|
||||
if file_name:
|
||||
file_name = ' in "%s"' % file_name
|
||||
return QMessageBox.question(self, 'Save file?', '''
|
||||
<h2>Do you want to save changes%s?</h2>
|
||||
<p>
|
||||
Your changes will be lost if you don't save them!
|
||||
</p>
|
||||
''' % file_name,
|
||||
QMessageBox.Cancel | QMessageBox.Close |
|
||||
QMessageBox.Save, QMessageBox.Save)
|
||||
|
||||
def new_node(self, name, file_path=None):
|
||||
node = NodeDisplay(file_path)
|
||||
if node.created:
|
||||
node.tree_widget.currentItemChanged.connect(self.disable_btns)
|
||||
self.tabWidget.setCurrentIndex(self.tabWidget.addTab(node, name))
|
||||
|
||||
def disable_btns(self, current, previous):
|
||||
cur_kind = current.kind if current else None
|
||||
self.add_parameter_btn.setEnabled(True)
|
||||
self.add_property_btn.setEnabled(True)
|
||||
self.add_comment_btn.setEnabled(True)
|
||||
self.duplicate_btn.setEnabled(True)
|
||||
self.delete_btn.setEnabled(True)
|
||||
if cur_kind is None:
|
||||
self.add_parameter_btn.setEnabled(False)
|
||||
self.add_property_btn.setEnabled(False)
|
||||
self.add_comment_btn.setEnabled(False)
|
||||
self.duplicate_btn.setEnabled(False)
|
||||
self.delete_btn.setEnabled(False)
|
||||
elif cur_kind not in [MODULE, INTERFACE]:
|
||||
self.duplicate_btn.setEnabled(False)
|
||||
if cur_kind == NODE:
|
||||
self.duplicate_btn.setEnabled(False)
|
||||
self.delete_btn.setEnabled(False)
|
||||
elif cur_kind == INTERFACE:
|
||||
self.add_parameter_btn.setEnabled(False)
|
||||
elif cur_kind == PARAMETER:
|
||||
self.add_parameter_btn.setEnabled(False)
|
||||
elif cur_kind == PROPERTY:
|
||||
self.add_parameter_btn.setEnabled(False)
|
||||
self.add_property_btn.setEnabled(False)
|
||||
elif cur_kind == COMMENT:
|
||||
self.add_parameter_btn.setEnabled(False)
|
||||
self.add_property_btn.setEnabled(False)
|
||||
self.add_comment_btn.setEnabled(False)
|
||||
|
||||
def tab_relevant_btns_disable(self, index):
|
||||
if index == -1:
|
||||
enable = False
|
||||
self.duplicate_btn.setEnabled(enable)
|
||||
self.delete_btn.setEnabled(enable)
|
||||
else:
|
||||
enable = True
|
||||
self.save_btn.setEnabled(enable)
|
||||
self.add_module_btn.setEnabled(enable)
|
||||
self.add_interface_btn.setEnabled(enable)
|
||||
self.add_parameter_btn.setEnabled(enable)
|
||||
self.add_property_btn.setEnabled(enable)
|
||||
self.add_comment_btn.setEnabled(enable)
|
||||
67
frappy/gui/cfg_editor/node_display.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Sandra Seger <sandra.seger@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
from frappy.gui.cfg_editor.utils import loadUi
|
||||
from frappy.gui.qt import QHBoxLayout, QSizePolicy, QSpacerItem, Qt, QWidget
|
||||
|
||||
|
||||
class NodeDisplay(QWidget):
|
||||
def __init__(self, file_path=None, parent=None):
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'node_display.ui')
|
||||
self.saved = bool(file_path)
|
||||
self.created = self.tree_widget.set_file(file_path)
|
||||
self.tree_widget.save_status_changed.connect(self.change_save_status)
|
||||
self.tree_widget.currentItemChanged.connect(self.set_scroll_area)
|
||||
self.scroll_area_layout.setAlignment(Qt.AlignTop)
|
||||
self.set_scroll_area(self.tree_widget.get_selected_item(), None)
|
||||
self.splitter.setSizes([1, 1])
|
||||
|
||||
def change_save_status(self, saved):
|
||||
self.saved = saved
|
||||
|
||||
def set_scroll_area(self, current, previous):
|
||||
self.remove_all_from_scroll_area(self.scroll_area_layout)
|
||||
self.scroll_area_layout.addWidget(current.widget)
|
||||
for index in range(0, current.childCount()):
|
||||
child_layout = QHBoxLayout()
|
||||
spacer = QSpacerItem(30, 0, QSizePolicy.Fixed,
|
||||
QSizePolicy.Minimum)
|
||||
child_layout.addSpacerItem(spacer)
|
||||
child_layout.addWidget(current.child(index).widget)
|
||||
self.scroll_area_layout.addLayout(child_layout)
|
||||
for sub_index in range(0, current.child(index).childCount()):
|
||||
sub_child_layout = QHBoxLayout()
|
||||
sub_spacer = QSpacerItem(60, 0, QSizePolicy.Fixed,
|
||||
QSizePolicy.Minimum)
|
||||
sub_child_layout.addSpacerItem(sub_spacer)
|
||||
sub_child_layout.addWidget(
|
||||
current.child(index).child(sub_index).widget)
|
||||
self.scroll_area_layout.addLayout(sub_child_layout)
|
||||
|
||||
def remove_all_from_scroll_area(self, layout):
|
||||
for index in range(layout.count()-1, -1, -1):
|
||||
item = layout.itemAt(index)
|
||||
if item.widget():
|
||||
item.widget().setParent(None)
|
||||
elif item.layout():
|
||||
self.remove_all_from_scroll_area(item.layout())
|
||||
223
frappy/gui/cfg_editor/tree_widget_item.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Sandra Seger <sandra.seger@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
from frappy.gui.cfg_editor.utils import loadUi, \
|
||||
set_name_edit_style, setIcon, setTreeIcon
|
||||
from frappy.gui.qt import QDialog, QFont, QHBoxLayout, \
|
||||
QLabel, QPushButton, QSize, QSizePolicy, QTextEdit, \
|
||||
QTreeWidgetItem, QVBoxLayout, QWidget, pyqtSignal
|
||||
from frappy.gui.valuewidgets import get_widget
|
||||
from frappy.properties import Property
|
||||
|
||||
NODE = 'node'
|
||||
INTERFACE = 'interface'
|
||||
MODULE = 'module'
|
||||
PARAMETER = 'parameter'
|
||||
PROPERTY = 'property'
|
||||
COMMENT = 'comment'
|
||||
|
||||
|
||||
class TreeWidgetItem(QTreeWidgetItem):
|
||||
def __init__(self, kind=None, name='', value=None, class_object=None,
|
||||
parameters=None, properties=None, parent=None):
|
||||
"""object_class: for interfaces and modules = class
|
||||
for parameter and properties = their objects
|
||||
the datatype passed onto ValueWidget should be on of frappy.datatypes"""
|
||||
# TODO: like stated in docstring the datatype for parameters and
|
||||
# properties must be found out through their object
|
||||
super().__init__(parent)
|
||||
self.kind = kind
|
||||
self.name = name
|
||||
self.class_object = class_object
|
||||
self.parameters = parameters or {}
|
||||
self.properties = properties or {}
|
||||
if self.kind and self.kind != 'node':
|
||||
setTreeIcon(self, '%s.png' % self.kind)
|
||||
else:
|
||||
setTreeIcon(self, 'empty.png')
|
||||
font = QFont()
|
||||
font.setWeight(QFont.Bold)
|
||||
self.setFont(0, font)
|
||||
self.setText(0, self.name)
|
||||
self.duplicates = 0
|
||||
datatype = None if type(class_object) != Property else \
|
||||
class_object.datatype
|
||||
self.widget = ValueWidget(name, value, datatype, kind)
|
||||
if kind in [NODE, MODULE, INTERFACE]:
|
||||
self.widget.edit_btn.clicked.connect(self.change_name)
|
||||
|
||||
def addChild(self, item):
|
||||
QTreeWidgetItem.addChild(self, item)
|
||||
item.setExpanded(True)
|
||||
|
||||
def duplicate(self):
|
||||
self.duplicates += 1
|
||||
duplicate = TreeWidgetItem(self.kind, '%s_%i' % (self.name,
|
||||
self.duplicates), self.get_value(),
|
||||
self.class_object)
|
||||
self.parent().addChild(duplicate)
|
||||
for i in range(self.childCount()):
|
||||
child = self.child(i)
|
||||
duplicate.addChild(TreeWidgetItem(child.kind,
|
||||
child.name, child.widget.get_value()))
|
||||
for k in range(child.childCount()):
|
||||
sub_child = child.child(k)
|
||||
duplicate.child(i).addChild(TreeWidgetItem(sub_child.kind,
|
||||
sub_child.name,
|
||||
sub_child.widget.get_value(),
|
||||
sub_child.datatype))
|
||||
|
||||
def set_name(self, name):
|
||||
self.name = name
|
||||
self.setText(0, self.name)
|
||||
self.widget.set_name(name)
|
||||
|
||||
def get_value(self):
|
||||
return self.widget.get_value()
|
||||
|
||||
def set_value(self, value):
|
||||
self.widget.set_value(value)
|
||||
|
||||
def set_class_object(self, class_obj, value=''):
|
||||
# TODO: should do stuff here if module or interface class is changed or
|
||||
# datatype
|
||||
self.class_object = class_obj
|
||||
datatype = None if type(self.class_object) != Property else \
|
||||
self.class_object.datatype
|
||||
self.widget.set_datatype(datatype, value)
|
||||
|
||||
def get_children_names(self):
|
||||
children = []
|
||||
for i in range(0, self.childCount()):
|
||||
children.append(self.child(i).name)
|
||||
return children
|
||||
|
||||
def change_name(self):
|
||||
if self.parent():
|
||||
invalid_names = self.parent().get_children_names()
|
||||
invalid_names.remove(self.name)
|
||||
else:
|
||||
invalid_names = ['']
|
||||
dialog = ChangeNameDialog(self.name, invalid_names)
|
||||
new_name = dialog.get_values()
|
||||
if new_name:
|
||||
self.set_name(new_name)
|
||||
|
||||
|
||||
class ValueWidget(QWidget):
|
||||
|
||||
save_status_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, name='', value='', datatype=None, kind='', parent=None):
|
||||
# TODO: implement: change module/interface class
|
||||
super().__init__(parent)
|
||||
self.datatype = datatype
|
||||
self.layout = QVBoxLayout()
|
||||
self.name_label = QLabel(name)
|
||||
self.name_label.setStyleSheet('font-weight: bold')
|
||||
self.kind = kind
|
||||
if self.kind in [NODE, MODULE, INTERFACE]:
|
||||
self.edit_btn = QPushButton()
|
||||
setIcon(self.edit_btn, 'edit.png')
|
||||
self.edit_btn.setIconSize(QSize(18, 18))
|
||||
self.edit_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.edit_btn.setFlat(True)
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(self.name_label)
|
||||
layout.addWidget(self.edit_btn)
|
||||
self.layout.addLayout(layout)
|
||||
else:
|
||||
self.layout.addWidget(self.name_label)
|
||||
# TODO value_display.valueChanged.connect(emit_save_status_changed) ->
|
||||
# implement or map a valueChanged signal for all possible
|
||||
# value_displays:
|
||||
# String = QLineEdit = textChanged
|
||||
# Enum = QComboBox = editTextChanged
|
||||
# Bool = QCheckBox = stateChanged
|
||||
# Int, Float = Q(Double)SpinBox = ValueChanged
|
||||
# Struct, Array = QGroupBox = clicked
|
||||
# Tuple = QFrame = ???
|
||||
if self.kind == PROPERTY and datatype and name != 'datatype':
|
||||
# TODO what to do if property is datatype
|
||||
self.value_display = get_widget(datatype)
|
||||
self.value_display.set_value(value)
|
||||
elif self.kind in [NODE, COMMENT]:
|
||||
self.value_display = QTextEdit()
|
||||
self.value_display.text = self.value_display.toPlainText
|
||||
self.value_display.setText = self.value_display.setPlainText
|
||||
self.value_display.textChanged.connect(self.emit_save_status_changed)
|
||||
self.set_value(value)
|
||||
else:
|
||||
self.value_display = QLabel(value)
|
||||
self.layout.addWidget(self.value_display)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def get_value(self):
|
||||
if self.datatype:
|
||||
return self.value_display.get_value()
|
||||
return self.value_display.text()
|
||||
|
||||
def set_value(self, value):
|
||||
# TODO try block
|
||||
if self.datatype:
|
||||
self.value_display.set_value(value)
|
||||
else:
|
||||
self.value_display.setText(value)
|
||||
|
||||
def set_name(self, name):
|
||||
if name != self.name_label.text():
|
||||
self.emit_save_status_changed(False)
|
||||
self.name_label.setText(name)
|
||||
|
||||
def set_datatype(self, datatype, value=''):
|
||||
if datatype == self.datatype:
|
||||
return
|
||||
# TODO: remove old value_display
|
||||
self.datatype = datatype
|
||||
if self.kind == PROPERTY and datatype:
|
||||
self.value_display = get_widget(datatype)
|
||||
self.value_display.set_value(value)
|
||||
else:
|
||||
self.value_display = QLabel(value)
|
||||
|
||||
def emit_save_status_changed(self, status=False):
|
||||
self.save_status_changed.emit(status)
|
||||
|
||||
|
||||
class ChangeNameDialog(QDialog):
|
||||
def __init__(self, current_name='', invalid_names=None, parent=None):
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'change_name_dialog.ui')
|
||||
self.invalid_names = invalid_names
|
||||
self.name.setText(current_name)
|
||||
self.name.selectAll()
|
||||
# TODO: input mask
|
||||
self.name.textChanged.connect(self.check_input)
|
||||
|
||||
def get_values(self):
|
||||
if self.exec_() == QDialog.Accepted:
|
||||
return self.name.text()
|
||||
return None
|
||||
|
||||
def check_input(self, name):
|
||||
set_name_edit_style(name in self.invalid_names or name == '', self.name,
|
||||
self.button_box)
|
||||
70
frappy/gui/cfg_editor/ui/add_dialog.ui
Normal file
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>add_dialog</class>
|
||||
<widget class="QDialog" name="add_dialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>250</width>
|
||||
<height>136</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>add dialog</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QGridLayout" name="add_layout"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="button_box">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<tabstops>
|
||||
<tabstop>button_box</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>button_box</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>add_dialog</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>248</x>
|
||||
<y>254</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>button_box</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>add_dialog</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>316</x>
|
||||
<y>260</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
74
frappy/gui/cfg_editor/ui/change_name_dialog.ui
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>change_name_dialog</class>
|
||||
<widget class="QDialog" name="change_name_dialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>239</width>
|
||||
<height>81</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>change name</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="name"/>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>name:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDialogButtonBox" name="button_box">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>button_box</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>change_name_dialog</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>248</x>
|
||||
<y>254</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>button_box</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>change_name_dialog</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>316</x>
|
||||
<y>260</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
BIN
frappy/gui/cfg_editor/ui/icons/add_comment.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/add_interface.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/add_module.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/add_parameter.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/add_property.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
20
frappy/gui/cfg_editor/ui/icons/cfg-editor.qrc
Normal file
@@ -0,0 +1,20 @@
|
||||
<RCC>
|
||||
<qresource>
|
||||
<file>delete.png</file>
|
||||
<file>duplicate.png</file>
|
||||
<file>add_comment.png</file>
|
||||
<file>add_interface.png</file>
|
||||
<file>add_module.png</file>
|
||||
<file>add_parameter.png</file>
|
||||
<file>add_property.png</file>
|
||||
<file>comment.png</file>
|
||||
<file>interface.png</file>
|
||||
<file>module.png</file>
|
||||
<file>parameter.png</file>
|
||||
<file>property.png</file>
|
||||
<file>edit.png</file>
|
||||
<file>new.png</file>
|
||||
<file>open.png</file>
|
||||
<file>save.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
BIN
frappy/gui/cfg_editor/ui/icons/comment.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/delete.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/duplicate.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/edit.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/empty.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/interface.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/module.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/new.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/open.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/parameter.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/property.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frappy/gui/cfg_editor/ui/icons/save.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
969
frappy/gui/cfg_editor/ui/mainwindow.ui
Normal file
@@ -0,0 +1,969 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>977</width>
|
||||
<height>799</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>9</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>cfg-editor</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="icon_layout">
|
||||
<item>
|
||||
<widget class="QToolButton" name="new_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>create new SEC node</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>new</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/new.png</normaloff>
|
||||
<normalon>:/new.png</normalon>:/new.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="open_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>open file</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>open</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/open.png</normaloff>
|
||||
<normalon>:/open.png</normalon>:/open.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="save_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>save</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>save</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/save.png</normaloff>
|
||||
<normalon>:/save.png</normalon>:/save.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_interface_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>add interface</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>add interface</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/add_interface.png</normaloff>
|
||||
<normalon>:/add_interface.png</normalon>:/add_interface.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_module_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>add module</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>add module</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/add_module.png</normaloff>
|
||||
<normalon>:/add_module.png</normalon>:/add_module.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_parameter_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>add parameter</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>add parameter</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/add_parameter.png</normaloff>
|
||||
<normalon>:/add_parameter.png</normalon>:/add_parameter.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_property_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>add property</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>add property</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset>
|
||||
<normalon>:/add_property.png</normalon>
|
||||
</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_comment_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>add comment</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>add comment</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/add_comment.png</normaloff>:/add_comment.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="line_2">
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>9</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="lineWidth">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="duplicate_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>duplicate module or interface</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>duplicate</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/duplicate.png</normaloff>
|
||||
<normalon>:/duplicate.png</normalon>:/duplicate.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="delete_btn">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>delete</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>delete</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/delete.png</normaloff>
|
||||
<normalon>:/delete.png</normalon>:/delete.png</iconset>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>60</width>
|
||||
<height>60</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolButtonStyle">
|
||||
<enum>Qt::ToolButtonTextUnderIcon</enum>
|
||||
</property>
|
||||
<property name="autoRaise">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="main_layout">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="contextMenuPolicy">
|
||||
<enum>Qt::DefaultContextMenu</enum>
|
||||
</property>
|
||||
<property name="tabShape">
|
||||
<enum>QTabWidget::Rounded</enum>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>-1</number>
|
||||
</property>
|
||||
<property name="documentMode">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="tabsClosable">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="movable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>977</width>
|
||||
<height>20</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>&File</string>
|
||||
</property>
|
||||
<addaction name="actionNew"/>
|
||||
<addaction name="actionOpen"/>
|
||||
<addaction name="actionSave"/>
|
||||
<addaction name="actionSave_as"/>
|
||||
<addaction name="action_Close"/>
|
||||
<addaction name="actionQuit"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menu_Help">
|
||||
<property name="title">
|
||||
<string>&Help</string>
|
||||
</property>
|
||||
<addaction name="actionAbout"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menu_Edit">
|
||||
<property name="title">
|
||||
<string>&Edit</string>
|
||||
</property>
|
||||
<addaction name="actionAdd_interface"/>
|
||||
<addaction name="actionAdd_module"/>
|
||||
<addaction name="actionAdd_parameter"/>
|
||||
<addaction name="actionAdd_property"/>
|
||||
<addaction name="actionAdd_comment"/>
|
||||
<addaction name="actionDuplicate"/>
|
||||
<addaction name="actionDelete"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menu_Edit"/>
|
||||
<addaction name="menu_Help"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<action name="actionNew">
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/new.png</normaloff>
|
||||
<normalon>:/new.png</normalon>:/new.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>&New</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Create new file</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+N</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionOpen">
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/open.png</normaloff>
|
||||
<normalon>:/open.png</normalon>:/open.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>&Open ...</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+O</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave">
|
||||
<property name="icon">
|
||||
<iconset resource="icons/cfg-editor.qrc">
|
||||
<normaloff>:/save.png</normaloff>
|
||||
<normalon>:/save.png</normalon>:/save.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>&Save</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+S</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave_as">
|
||||
<property name="text">
|
||||
<string>Save &as ...</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Shift+S</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout">
|
||||
<property name="text">
|
||||
<string>&About</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+A</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionQuit">
|
||||
<property name="text">
|
||||
<string>&Quit</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Q</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_SEC_node">
|
||||
<property name="text">
|
||||
<string>add SEC node</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_interface">
|
||||
<property name="text">
|
||||
<string>add interface</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_module">
|
||||
<property name="text">
|
||||
<string>add module</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_parameter">
|
||||
<property name="text">
|
||||
<string>add parameter</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_property">
|
||||
<property name="text">
|
||||
<string>add property</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_comment">
|
||||
<property name="text">
|
||||
<string>add comment</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionDuplicate">
|
||||
<property name="text">
|
||||
<string>duplicate</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionDelete">
|
||||
<property name="text">
|
||||
<string>delete</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="action_Close">
|
||||
<property name="text">
|
||||
<string>&Close</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+F4</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="icons/cfg-editor.qrc"/>
|
||||
</resources>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>new_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionNew()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>39</x>
|
||||
<y>76</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>3</x>
|
||||
<y>91</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>open_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionOpen()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>132</x>
|
||||
<y>81</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>3</x>
|
||||
<y>45</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>save_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionSave()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>222</x>
|
||||
<y>59</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>0</x>
|
||||
<y>25</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>add_interface_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_interface()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>346</x>
|
||||
<y>83</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>328</x>
|
||||
<y>24</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>add_module_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_module()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>432</x>
|
||||
<y>61</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>401</x>
|
||||
<y>24</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>add_parameter_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_parameter()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>527</x>
|
||||
<y>76</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>499</x>
|
||||
<y>24</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>add_property_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_property()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>644</x>
|
||||
<y>71</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>613</x>
|
||||
<y>24</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>add_comment_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_comment()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>732</x>
|
||||
<y>74</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>708</x>
|
||||
<y>20</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>duplicate_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_duplicate()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>831</x>
|
||||
<y>87</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>818</x>
|
||||
<y>24</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>delete_btn</sender>
|
||||
<signal>clicked()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_delete()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>919</x>
|
||||
<y>55</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>909</x>
|
||||
<y>21</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionNew</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionNew()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionOpen</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionOpen()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionSave</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionSave()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionSave_as</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionSave_as()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionQuit</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionQuit()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionAbout</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_actionAbout()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionAdd_interface</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_interface()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionAdd_module</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_module()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionAdd_parameter</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_parameter()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionAdd_property</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_property()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionAdd_comment</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_add_comment()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionDuplicate</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_duplicate()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>actionDelete</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_delete()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>action_Close</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>on_action_Close()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>488</x>
|
||||
<y>399</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
<slots>
|
||||
<slot>on_actionNew()</slot>
|
||||
<slot>on_add_module()</slot>
|
||||
<slot>on_add_interface()</slot>
|
||||
<slot>on_add_parameter()</slot>
|
||||
<slot>on_add_property()</slot>
|
||||
<slot>on_add_comment()</slot>
|
||||
<slot>on_actionOpen()</slot>
|
||||
<slot>on_actionSave()</slot>
|
||||
<slot>on_duplicate()</slot>
|
||||
<slot>on_delete()</slot>
|
||||
<slot>on_actionSave_as()</slot>
|
||||
<slot>on_actionQuit()</slot>
|
||||
<slot>on_actionAbout()</slot>
|
||||
<slot>on_action_Close()</slot>
|
||||
</slots>
|
||||
</ui>
|
||||
89
frappy/gui/cfg_editor/ui/node_display.ui
Normal file
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>945</width>
|
||||
<height>671</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="opaqueResize">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="handleWidth">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<property name="childrenCollapsible">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="TreeWidget" name="tree_widget">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExpandDelay">
|
||||
<number>-1</number>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<attribute name="headerVisible">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string notr="true">1</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
<widget class="QScrollArea" name="scrollArea">
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="scrollAreaWidgetContent">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>73</width>
|
||||
<height>651</height>
|
||||
</rect>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="scroll_area_layout"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>TreeWidget</class>
|
||||
<extends>QTreeWidget</extends>
|
||||
<header>frappy.gui.cfg_editor.widgets</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
193
frappy/gui/cfg_editor/utils.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Sandra Seger <sandra.seger@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from os import listdir, path
|
||||
|
||||
from frappy.gui.qt import QDialogButtonBox, QFileDialog, QIcon, QSize, uic
|
||||
from frappy.modules import Module
|
||||
from frappy.params import Parameter
|
||||
from frappy.properties import Property
|
||||
from frappy.protocol.interface.tcp import TCPServer
|
||||
from frappy.server import generalConfig
|
||||
|
||||
uipath = path.dirname(__file__)
|
||||
|
||||
|
||||
def loadUi(widget, uiname, subdir='ui'):
|
||||
uic.loadUi(path.join(uipath, subdir, uiname), widget)
|
||||
|
||||
|
||||
def setIcon(widget, icon_name, subdir='ui', icondir='icons'):
|
||||
widget.setIcon(QIcon(path.join(uipath, subdir, icondir, icon_name)))
|
||||
widget.setIconSize(QSize(60, 60))
|
||||
|
||||
|
||||
def set_name_edit_style(invalid, name_edit, button_box=None):
|
||||
if invalid:
|
||||
name_edit.setStyleSheet("color: red")
|
||||
name_edit.setToolTip('Invalid name: name already taken')
|
||||
if button_box:
|
||||
button_box.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
else:
|
||||
name_edit.setStyleSheet("color: black")
|
||||
name_edit.setToolTip('')
|
||||
if button_box:
|
||||
button_box.button(QDialogButtonBox.Ok).setEnabled(True)
|
||||
|
||||
|
||||
def setTreeIcon(widget, icon_name, subdir='ui', icondir='icons'):
|
||||
widget.setIcon(0, QIcon(path.join(uipath, subdir, icondir, icon_name)))
|
||||
|
||||
|
||||
def setActionIcon(widget, icon_name, subdir='ui', icondir='icons'):
|
||||
widget.setIcon(QIcon(path.join(uipath, subdir, icondir, icon_name)))
|
||||
|
||||
|
||||
def get_subtree_nodes(tree_widget_item):
|
||||
nodes = []
|
||||
nodes.append(tree_widget_item)
|
||||
for i in range(tree_widget_item.childCount()):
|
||||
nodes.extend(get_subtree_nodes(tree_widget_item.child(i)))
|
||||
return nodes
|
||||
|
||||
|
||||
def get_all_children_with_names(tree_widget_item):
|
||||
children = {}
|
||||
for i in range(0, tree_widget_item.childCount()):
|
||||
children[tree_widget_item.child(i).name] = tree_widget_item.child(i)
|
||||
return children
|
||||
|
||||
|
||||
def get_all_items(tree_widget):
|
||||
all_items = []
|
||||
for i in range(tree_widget.topLevelItemCount()):
|
||||
top_item = tree_widget.topLevelItem(i)
|
||||
all_items.extend(get_subtree_nodes(top_item))
|
||||
return all_items
|
||||
|
||||
|
||||
def get_file_paths(widget, open_file=True):
|
||||
dialog = QFileDialog(widget)
|
||||
if open_file:
|
||||
title = 'Open file'
|
||||
dialog.setAcceptMode(QFileDialog.AcceptOpen)
|
||||
dialog.setFileMode(QFileDialog.ExistingFiles)
|
||||
else:
|
||||
title = 'Save file'
|
||||
dialog.setAcceptMode(QFileDialog.AcceptSave)
|
||||
dialog.setFileMode(QFileDialog.AnyFile)
|
||||
dialog.setWindowTitle(title)
|
||||
dialog.setNameFilter('*.cfg')
|
||||
dialog.setDefaultSuffix('.cfg')
|
||||
dialog.exec_()
|
||||
return dialog.selectedFiles()
|
||||
|
||||
|
||||
def get_modules():
|
||||
modules = {}
|
||||
base_path = generalConfig.basedir
|
||||
# pylint: disable=too-many-nested-blocks
|
||||
for dirname in listdir(base_path):
|
||||
if dirname.startswith('frappy_'):
|
||||
modules[dirname] = {}
|
||||
for filename in listdir(path.join(base_path, dirname)):
|
||||
if not path.isfile(path.join(base_path, dirname, filename)) or \
|
||||
filename == '__init__.py' or filename[-3:] != '.py':
|
||||
continue
|
||||
module = '%s.%s' % (dirname, filename[:-3])
|
||||
module_in_file = False
|
||||
try:
|
||||
__import__(module)
|
||||
for name, obj in inspect.getmembers(sys.modules[module]):
|
||||
if inspect.isclass(obj) and \
|
||||
obj.__module__.startswith('frappy_') and \
|
||||
issubclass(obj, Module):
|
||||
# full_name = '%s.%s' % (obj.__module__, name)
|
||||
if not module_in_file:
|
||||
modules[dirname][filename[:-3]] = []
|
||||
module_in_file = True
|
||||
modules[dirname][filename[:-3]].append(name)
|
||||
except ImportError:
|
||||
pass
|
||||
return modules
|
||||
|
||||
|
||||
def get_module_class_from_name(name):
|
||||
try:
|
||||
last_dot = name.rfind('.')
|
||||
class_name = name[last_dot+1:]
|
||||
module = name[:last_dot]
|
||||
__import__(module)
|
||||
for cls_name, obj in inspect.getmembers(sys.modules[module]):
|
||||
if inspect.isclass(obj) and obj.__module__.startswith('frappy_') \
|
||||
and issubclass(obj, Module) and cls_name == class_name:
|
||||
return obj
|
||||
except ImportError:
|
||||
pass
|
||||
return -1
|
||||
|
||||
|
||||
def get_interface_class_from_name(name):
|
||||
# TODO: return the class of name and not TCPServer hard coded
|
||||
return TCPServer
|
||||
|
||||
|
||||
def get_interfaces():
|
||||
# TODO class must be found out like for modules
|
||||
interfaces = []
|
||||
interface_path = path.join(generalConfig.basedir, 'frappy',
|
||||
'protocol', 'interface')
|
||||
for filename in listdir(interface_path):
|
||||
if path.isfile(path.join(interface_path, filename)) and \
|
||||
filename != '__init__.py' and filename[-3:] == '.py':
|
||||
interfaces.append(filename[:-3])
|
||||
return interfaces
|
||||
|
||||
|
||||
def get_params(info):
|
||||
"""returns all parameter of a module with all properties of all parameter
|
||||
as dictionary: {parameter name: [Parameter object, {property name, Property object}], ...}"""
|
||||
params = {}
|
||||
try:
|
||||
conf = info.configurables
|
||||
for access in info.accessibles:
|
||||
if type(info.accessibles[access]) == Parameter:
|
||||
params[access] = [info.accessibles[access], conf[access]]
|
||||
except AttributeError:
|
||||
return {}
|
||||
return params
|
||||
|
||||
|
||||
def get_props(info):
|
||||
"""returns all properties of a module class, interface class or parameter
|
||||
as dictionary: {property name: Property object, ...}"""
|
||||
props = {}
|
||||
try:
|
||||
conf = info.configurables
|
||||
for name, value in conf.items():
|
||||
if type(value) == Property:
|
||||
props[name] = value
|
||||
except AttributeError:
|
||||
return {}
|
||||
return props
|
||||
479
frappy/gui/cfg_editor/widgets.py
Normal file
@@ -0,0 +1,479 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Sandra Seger <sandra.seger@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
import os
|
||||
|
||||
from frappy.gui.cfg_editor.config_file import read_config, write_config
|
||||
from frappy.gui.cfg_editor.tree_widget_item import TreeWidgetItem
|
||||
from frappy.gui.cfg_editor.utils import get_all_items, \
|
||||
get_file_paths, get_interface_class_from_name, get_interfaces, \
|
||||
get_module_class_from_name, get_modules, get_params, \
|
||||
get_props, loadUi, set_name_edit_style, setActionIcon
|
||||
from frappy.gui.qt import QComboBox, QDialog, QDialogButtonBox, QLabel, \
|
||||
QLineEdit, QMenu, QPoint, QSize, QStandardItem, QStandardItemModel, \
|
||||
Qt, QTabBar, QTextEdit, QTreeView, QTreeWidget, pyqtSignal
|
||||
|
||||
NODE = 'node'
|
||||
MODULE = 'module'
|
||||
INTERFACE = 'interface'
|
||||
PARAMETER = 'parameter'
|
||||
PROPERTY = 'property'
|
||||
COMMENT = 'comment'
|
||||
|
||||
|
||||
class TreeWidget(QTreeWidget):
|
||||
|
||||
save_status_changed = pyqtSignal(bool)
|
||||
add_canceled = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.file_path = None
|
||||
self.setIconSize(QSize(24, 24))
|
||||
self.setSelectionMode(QTreeWidget.SingleSelection)
|
||||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.context_pos = QPoint(0, 0)
|
||||
self.menu = QMenu()
|
||||
self.context_actions = []
|
||||
self.invalid_context_actions = {}
|
||||
self.setup_context_actions()
|
||||
self.customContextMenuRequested.connect(self.on_context_menu_requested)
|
||||
|
||||
def setup_context_actions(self):
|
||||
edit = self.menu.addAction('rename')
|
||||
a_m = self.menu.addAction('add module')
|
||||
a_i = self.menu.addAction('add interface')
|
||||
a_pa = self.menu.addAction('add parameter')
|
||||
a_pr = self.menu.addAction('add property')
|
||||
a_c = self.menu.addAction('add comment')
|
||||
dup = self.menu.addAction('duplicate')
|
||||
dele = self.menu.addAction('delete')
|
||||
self.context_actions = [edit, a_m, a_i, a_pa, a_pr, a_c, dup, dele]
|
||||
setActionIcon(edit, 'edit.png')
|
||||
setActionIcon(a_m, 'add_module.png')
|
||||
setActionIcon(a_i, 'add_interface.png')
|
||||
setActionIcon(a_pa, 'add_parameter.png')
|
||||
setActionIcon(a_pr, 'add_property.png')
|
||||
setActionIcon(a_c, 'add_comment.png')
|
||||
setActionIcon(dup, 'duplicate.png')
|
||||
setActionIcon(dele, 'delete.png')
|
||||
self.invalid_context_actions = {NODE: [a_pa, dup, dele],
|
||||
MODULE: [],
|
||||
INTERFACE: [a_pa],
|
||||
PARAMETER: [edit, a_pa, dup],
|
||||
PROPERTY: [edit, a_pa, a_pr, dup],
|
||||
COMMENT: [edit, a_pa, a_pr, a_c, dup],
|
||||
None: [edit, a_pa, a_pr, a_c, dup,
|
||||
dele]}
|
||||
edit.triggered.connect(self.change_name_via_context)
|
||||
a_m.triggered.connect(self.add_module)
|
||||
a_i.triggered.connect(self.add_interface)
|
||||
a_pa.triggered.connect(self.add_parameter)
|
||||
a_pr.triggered.connect(self.add_property)
|
||||
a_c.triggered.connect(self.add_comment)
|
||||
dup.triggered.connect(self.duplicate)
|
||||
dele.triggered.connect(self.delete)
|
||||
|
||||
def emit_save_status_changed(self, status):
|
||||
self.save_status_changed.emit(status)
|
||||
|
||||
def set_file(self, file_path):
|
||||
self.file_path = file_path
|
||||
if self.file_path:
|
||||
if os.path.isfile(file_path):
|
||||
self.set_tree(read_config(self.file_path))
|
||||
self.emit_save_status_changed(True)
|
||||
return True
|
||||
self.file_path = None
|
||||
return self.new_tree()
|
||||
|
||||
def new_tree(self):
|
||||
dialog = AddDialog(NODE)
|
||||
values = dialog.get_values()
|
||||
if values:
|
||||
sec_node = self.get_tree_widget_item(NODE, values[0], values[1],
|
||||
None)
|
||||
self.addTopLevelItem(sec_node)
|
||||
sec_node.setExpanded(True)
|
||||
self.mods = self.get_tree_widget_item(name='modules')
|
||||
self.ifs = self.get_tree_widget_item(name='interfaces')
|
||||
sec_node.addChild(self.ifs)
|
||||
sec_node.addChild(self.mods)
|
||||
self.emit_save_status_changed(False)
|
||||
self.setCurrentItem(sec_node)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_tree(self, tree_items):
|
||||
self.clear()
|
||||
self.addTopLevelItem(tree_items[0])
|
||||
self.ifs = tree_items[1]
|
||||
self.mods = tree_items[2]
|
||||
self.expandAll()
|
||||
self.setCurrentItem(tree_items[0])
|
||||
for item in get_all_items(self):
|
||||
item.widget.save_status_changed.connect(self.emit_save_status_changed)
|
||||
|
||||
def add_module(self):
|
||||
dialog = AddDialog(MODULE, get_modules(),
|
||||
self.mods.get_children_names())
|
||||
values = dialog.get_values()
|
||||
if values:
|
||||
module_class = get_module_class_from_name(values[1])
|
||||
params = get_params(module_class)
|
||||
props = get_props(module_class)
|
||||
mod = self.get_tree_widget_item(MODULE, values[0], values[1],
|
||||
module_class, params, props)
|
||||
self.mods.addChild(mod)
|
||||
|
||||
# add all mandatory properties
|
||||
for pr, pr_o in props.items():
|
||||
# TODO: mandatory to must_be_configured
|
||||
if pr_o.mandatory is True:
|
||||
pr_i = self.get_tree_widget_item(PROPERTY, pr,
|
||||
pr_o.default, pr_o)
|
||||
mod.addChild(pr_i)
|
||||
# add all mandatory properties of parameter
|
||||
for pa, pa_o in params.items():
|
||||
pa_i = None
|
||||
for pr, pr_o in pa_o[1].items():
|
||||
# TODO: mandatory to must_be_configured
|
||||
if pr_o.mandatory is True:
|
||||
pr_i = self.get_tree_widget_item(PROPERTY, pr,
|
||||
pr_o.default, pr_o)
|
||||
if not pa_i:
|
||||
pa_i = self.get_tree_widget_item(PARAMETER, pa,
|
||||
class_object=pa_o[0],
|
||||
properties=pa_o[1])
|
||||
mod.addChild(pa_i)
|
||||
pa_i.addChild(pr_i)
|
||||
|
||||
self.setCurrentItem(mod)
|
||||
self.emit_save_status_changed(False)
|
||||
|
||||
def add_interface(self):
|
||||
dialog = AddDialog(INTERFACE, get_interfaces(),
|
||||
self.ifs.get_children_names())
|
||||
values = dialog.get_values()
|
||||
if values:
|
||||
interface_class = get_interface_class_from_name(values[1])
|
||||
props = get_props(interface_class)
|
||||
interface = self.get_tree_widget_item(INTERFACE, values[0],
|
||||
values[1], interface_class,
|
||||
properties=props)
|
||||
self.ifs.addChild(interface)
|
||||
# add all mandatory properties
|
||||
for pr, pr_o in props.items():
|
||||
# TODO: mandatory to must_be_configured
|
||||
if pr_o.mandatory is True:
|
||||
pr_i = self.get_tree_widget_item(PROPERTY, pr,
|
||||
pr_o.default, pr_o)
|
||||
interface.addChild(pr_i)
|
||||
self.setCurrentItem(interface)
|
||||
self.emit_save_status_changed(False)
|
||||
|
||||
def add_parameter(self):
|
||||
selected_item = self.get_selected_item()
|
||||
if not selected_item or self.is_heading(selected_item) or \
|
||||
self.is_not_extendable(selected_item) or \
|
||||
selected_item.kind in [PARAMETER, INTERFACE]:
|
||||
return
|
||||
params = selected_item.parameters
|
||||
if params:
|
||||
dialog = AddDialog(PARAMETER, params.keys())
|
||||
values = dialog.get_values()
|
||||
current_children = selected_item.get_children_names()
|
||||
if values:
|
||||
if values[0] in current_children:
|
||||
self.setCurrentItem(selected_item.child(current_children.index(
|
||||
values[0])))
|
||||
else:
|
||||
param = self.get_tree_widget_item(PARAMETER, values[0],
|
||||
class_object=params[values[0]][0],
|
||||
properties=params[values[0]][1])
|
||||
selected_item.insertChild(0, param)
|
||||
self.setCurrentItem(param)
|
||||
self.emit_save_status_changed(False)
|
||||
else:
|
||||
# TODO: warning
|
||||
pass
|
||||
|
||||
def add_property(self):
|
||||
selected_item = self.get_selected_item()
|
||||
if not selected_item or self.is_heading(selected_item) or \
|
||||
self.is_not_extendable(selected_item):
|
||||
return
|
||||
props = selected_item.properties
|
||||
if props:
|
||||
dialog = AddDialog(PROPERTY, props.keys())
|
||||
values = dialog.get_values()
|
||||
current_children = selected_item.get_children_names()
|
||||
if values:
|
||||
if values[0] in current_children:
|
||||
self.setCurrentItem(selected_item.child(current_children.index(
|
||||
values[0])))
|
||||
else:
|
||||
prop = self.get_tree_widget_item(PROPERTY, values[0],
|
||||
props[values[0]].default,
|
||||
props[values[0]])
|
||||
selected_item.insertChild(0, prop)
|
||||
self.setCurrentItem(prop)
|
||||
self.emit_save_status_changed(False)
|
||||
else:
|
||||
# TODO: warning
|
||||
pass
|
||||
|
||||
def add_comment(self):
|
||||
selected_item = self.get_selected_item()
|
||||
if not selected_item or self.is_heading(selected_item) or \
|
||||
selected_item.kind == COMMENT:
|
||||
return
|
||||
dialog = AddDialog(COMMENT)
|
||||
values = dialog.get_values()
|
||||
if values:
|
||||
comm = self.get_tree_widget_item(COMMENT, '#', values[0])
|
||||
selected_item.insertChild(0, comm)
|
||||
self.setCurrentItem(comm)
|
||||
self.emit_save_status_changed(False)
|
||||
|
||||
def duplicate(self):
|
||||
selected_item = self.get_selected_item()
|
||||
if not selected_item or selected_item.kind not in [MODULE, INTERFACE]:
|
||||
return
|
||||
selected_item.duplicate()
|
||||
# TODO set duplicated selected
|
||||
self.emit_save_status_changed(False)
|
||||
|
||||
def delete(self):
|
||||
selected_item = self.get_selected_item()
|
||||
if not selected_item or self.is_heading(selected_item) \
|
||||
or selected_item == NODE:
|
||||
return
|
||||
selected_item.parent().removeChild(selected_item)
|
||||
self.emit_save_status_changed(False)
|
||||
|
||||
def save(self, save_as=False):
|
||||
file_name = self.file_path
|
||||
if not self.file_path or save_as:
|
||||
file_name = get_file_paths(self, False)[-1]
|
||||
if file_name[-4:] == '.cfg':
|
||||
self.file_path = file_name
|
||||
write_config(self.file_path, self)
|
||||
self.emit_save_status_changed(True)
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_heading(self, item):
|
||||
return item is self.ifs or item is self.mods
|
||||
|
||||
def is_not_extendable(self, item):
|
||||
return item.kind in [PROPERTY, COMMENT]
|
||||
|
||||
def get_selected_item(self):
|
||||
selected_item = self.selectedItems()
|
||||
if not selected_item:
|
||||
return None
|
||||
return selected_item[-1]
|
||||
|
||||
def is_valid_name(self, name, kind):
|
||||
if kind == MODULE:
|
||||
if name in self.mods.get_children_names():
|
||||
return False
|
||||
elif kind == INTERFACE:
|
||||
if name in self.ifs.get_children_names():
|
||||
return False
|
||||
else:
|
||||
selected_item = self.get_selected_item()
|
||||
if not selected_item:
|
||||
return False
|
||||
if name in selected_item.get_children_names():
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_tree_widget_item(self, kind=None, name='', value=None,
|
||||
class_object=None, parameters=None, properties=None):
|
||||
item = TreeWidgetItem(kind, name, value, class_object, parameters or {}, properties or {})
|
||||
item.widget.save_status_changed.connect(self.emit_save_status_changed)
|
||||
return item
|
||||
|
||||
def change_name_via_context(self):
|
||||
self.itemAt(self.context_pos).change_name()
|
||||
|
||||
def on_context_menu_requested(self, pos):
|
||||
self.context_pos = pos
|
||||
self.menu.move(self.mapToGlobal(pos))
|
||||
self.menu.show()
|
||||
for action in self.context_actions:
|
||||
action.setEnabled(True)
|
||||
for action in self.invalid_context_actions[self.itemAt(self.context_pos).kind]:
|
||||
action.setEnabled(False)
|
||||
|
||||
|
||||
class AddDialog(QDialog):
|
||||
def __init__(self, kind, possible_values=None, invalid_names=None,
|
||||
parent=None):
|
||||
"""Notes:
|
||||
self.get_value: is mapped to the specific method for getting
|
||||
the value from self.value"""
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'add_dialog.ui')
|
||||
self.setWindowTitle('add %s' % kind)
|
||||
self.kind = kind
|
||||
self.invalid_names = invalid_names
|
||||
if self.invalid_names:
|
||||
for i, name in enumerate(self.invalid_names):
|
||||
self.invalid_names[i] = name.lower()
|
||||
if kind in [NODE, MODULE, INTERFACE]:
|
||||
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self.name = QLineEdit()
|
||||
# TODO: input mask
|
||||
self.name.textChanged.connect(self.check_input)
|
||||
self.add_layout.addWidget(QLabel('name:'), 0, 0)
|
||||
self.add_layout.addWidget(self.name, 0, 1)
|
||||
if kind == NODE:
|
||||
label_text = 'description:'
|
||||
self.value = QTextEdit()
|
||||
self.get_value = self.value.toPlainText
|
||||
self.value.text = self.value.toPlainText
|
||||
else:
|
||||
label_text = 'kind:'
|
||||
self.value = QComboBox()
|
||||
self.get_value = self.value.currentText
|
||||
if type(possible_values) == dict:
|
||||
# TODO disable OK Button if TreeComboBox is empty
|
||||
self.value = TreeComboBox(possible_values)
|
||||
self.get_value = self.value.get_value
|
||||
else:
|
||||
self.value.addItems(possible_values)
|
||||
self.value.setCurrentIndex(len(possible_values)-1)
|
||||
self.add_layout.addWidget(QLabel(label_text), 1, 0)
|
||||
self.add_layout.addWidget(self.value, 1, 1)
|
||||
else:
|
||||
if kind in [PARAMETER, PROPERTY]:
|
||||
label_text = 'kind:'
|
||||
self.value = QComboBox()
|
||||
self.get_value = self.value.currentText
|
||||
self.value.addItems(possible_values)
|
||||
else:
|
||||
label_text = 'comment:'
|
||||
self.value = QTextEdit()
|
||||
self.get_value = self.value.toPlainText
|
||||
self.add_layout.addWidget(QLabel(label_text), 0, 0)
|
||||
self.add_layout.addWidget(self.value, 0, 1)
|
||||
if self.add_layout.rowCount() == 2:
|
||||
self.setTabOrder(self.name, self.value)
|
||||
self.setTabOrder(self.value, self.button_box.button(QDialogButtonBox.Ok))
|
||||
self.setTabOrder(self.button_box.button(QDialogButtonBox.Ok),
|
||||
self.button_box.button(QDialogButtonBox.Cancel))
|
||||
|
||||
def get_values(self):
|
||||
if self.exec_() == QDialog.Accepted:
|
||||
if self.kind in [NODE, MODULE, INTERFACE]:
|
||||
return [self.name.text(), self.get_value()]
|
||||
if self.kind in [PARAMETER, PROPERTY, COMMENT]:
|
||||
return [self.get_value()]
|
||||
return None
|
||||
|
||||
def check_input(self, name):
|
||||
set_name_edit_style((self.kind in [MODULE, INTERFACE] and
|
||||
name.lower() in self.invalid_names) or name == '',
|
||||
self.name, self.button_box)
|
||||
|
||||
|
||||
class TabBar(QTabBar):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.context_pos = QPoint(0, 0)
|
||||
self.menu = QMenu()
|
||||
close = self.menu.addAction('&Close')
|
||||
close_all = self.menu.addAction('&Close all')
|
||||
close.triggered.connect(self.close_tab_via_context)
|
||||
close_all.triggered.connect(self.close_all)
|
||||
self.customContextMenuRequested.connect(self.on_context_menu_requested)
|
||||
self.setMovable(True)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MidButton:
|
||||
self.close_tab_at_pos(event.pos())
|
||||
QTabBar.mouseReleaseEvent(self, event)
|
||||
|
||||
def on_context_menu_requested(self, pos):
|
||||
self.context_pos = pos
|
||||
self.menu.move(self.mapToGlobal(pos))
|
||||
self.menu.show()
|
||||
|
||||
def close_tab_via_context(self):
|
||||
self.close_tab_at_pos(self.context_pos)
|
||||
|
||||
def close_all(self):
|
||||
for i in range(self.count()-1, -1, -1):
|
||||
self.tabCloseRequested.emit(i)
|
||||
|
||||
def close_tab_at_pos(self, pos):
|
||||
self.tabCloseRequested.emit(self.tabAt(pos))
|
||||
|
||||
|
||||
class TreeComboBox(QComboBox):
|
||||
def __init__(self, value_dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.tree_view = QTreeView()
|
||||
self.tree_view.setHeaderHidden(True)
|
||||
self.tree_view.expanded.connect(self.resize_length)
|
||||
self.tree_view.collapsed.connect(self.resize_length)
|
||||
self.model = QStandardItemModel()
|
||||
self.insert_dict(value_dict)
|
||||
self.setModel(self.model)
|
||||
self.setView(self.tree_view)
|
||||
self.setStyleSheet('QTreeView::item:has-children{color: black;'
|
||||
'font: bold;}')
|
||||
|
||||
def insert_dict(self, value_dict, parent=None):
|
||||
for not_selectable in value_dict:
|
||||
act_item = QStandardItem(not_selectable)
|
||||
act_item.setEnabled(False)
|
||||
font = act_item.font()
|
||||
font.setBold(True)
|
||||
act_item.setFont(font)
|
||||
if parent:
|
||||
parent.appendRow([act_item])
|
||||
else:
|
||||
self.model.appendRow([act_item])
|
||||
if type(value_dict[not_selectable]) == dict:
|
||||
self.insert_dict(value_dict[not_selectable], act_item)
|
||||
else:
|
||||
for item in value_dict[not_selectable]:
|
||||
act_item.appendRow([QStandardItem(item)])
|
||||
|
||||
def get_value(self):
|
||||
value = ''
|
||||
act_index = self.tree_view.selectedIndexes()[0]
|
||||
act_item = act_index.model().itemFromIndex(act_index)
|
||||
value += act_item.text()
|
||||
while act_item.parent():
|
||||
value = '%s.%s' % (act_item.parent().text(), value)
|
||||
act_item = act_item.parent()
|
||||
return value
|
||||
|
||||
def resize_length(self):
|
||||
self.showPopup()
|
||||
5915
frappy/gui/icon_rc_qt4.py
Normal file
5960
frappy/gui/icon_rc_qt5.py
Normal file
238
frappy/gui/mainwindow.py
Normal file
@@ -0,0 +1,238 @@
|
||||
# -*- 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 <alexander.lenz@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
import frappy.client
|
||||
from frappy.gui.modulectrl import ModuleCtrl
|
||||
from frappy.gui.nodectrl import NodeCtrl
|
||||
from frappy.gui.paramview import ParameterView
|
||||
from frappy.gui.qt import QBrush, QColor, QInputDialog, QMainWindow, \
|
||||
QMessageBox, QObject, QTreeWidgetItem, pyqtSignal, pyqtSlot
|
||||
from frappy.gui.util import Value, loadUi
|
||||
from frappy.lib import formatExtendedTraceback
|
||||
|
||||
ITEM_TYPE_NODE = QTreeWidgetItem.UserType + 1
|
||||
ITEM_TYPE_GROUP = QTreeWidgetItem.UserType + 2
|
||||
ITEM_TYPE_MODULE = QTreeWidgetItem.UserType + 3
|
||||
ITEM_TYPE_PARAMETER = QTreeWidgetItem.UserType + 4
|
||||
|
||||
|
||||
class QSECNode(QObject):
|
||||
newData = pyqtSignal(str, str, object) # module, parameter, data
|
||||
stateChange = pyqtSignal(str, bool, str) # node name, online, connection state
|
||||
unhandledMsg = pyqtSignal(str) # message
|
||||
logEntry = pyqtSignal(str)
|
||||
|
||||
def __init__(self, uri, parent=None):
|
||||
super().__init__(parent)
|
||||
self.conn = conn = frappy.client.SecopClient(uri)
|
||||
conn.validate_data = True
|
||||
self.log = conn.log
|
||||
self.contactPoint = conn.uri
|
||||
conn.connect()
|
||||
self.equipmentId = conn.properties['equipment_id']
|
||||
self.nodename = '%s (%s)' % (self.equipmentId, conn.uri)
|
||||
self.modules = conn.modules
|
||||
self.properties = self.conn.properties
|
||||
self.protocolVersion = conn.secop_version
|
||||
conn.register_callback(None, self.updateEvent, self.nodeStateChange, self.unhandledMessage)
|
||||
|
||||
# provide methods from old baseclient for making other gui code work
|
||||
|
||||
def getParameters(self, module):
|
||||
return self.modules[module]['parameters']
|
||||
|
||||
def getCommands(self, module):
|
||||
return self.modules[module]['commands']
|
||||
|
||||
def getModuleProperties(self, module):
|
||||
return self.modules[module]['properties']
|
||||
|
||||
def getProperties(self, module, parameter):
|
||||
props = self.modules[module]['parameters'][parameter]
|
||||
if 'unit' in props['datainfo']:
|
||||
props['unit'] = props['datainfo']['unit']
|
||||
return self.modules[module]['parameters'][parameter]
|
||||
|
||||
def setParameter(self, module, parameter, value):
|
||||
# TODO: change the widgets for complex types to no longer use strings
|
||||
datatype = self.conn.modules[module]['parameters'][parameter]['datatype']
|
||||
self.conn.setParameter(module, parameter, datatype.from_string(value))
|
||||
|
||||
def getParameter(self, module, parameter):
|
||||
return self.conn.getParameter(module, parameter, True)
|
||||
|
||||
def execCommand(self, module, command, argument):
|
||||
return self.conn.execCommand(module, command, argument)
|
||||
|
||||
def queryCache(self, module):
|
||||
return {k: Value(*self.conn.cache[(module, k)])
|
||||
for k in self.modules[module]['parameters']}
|
||||
|
||||
def syncCommunicate(self, action, ident='', data=None):
|
||||
reply = self.conn.request(action, ident, data)
|
||||
# pylint: disable=not-an-iterable
|
||||
return frappy.client.encode_msg_frame(*reply).decode('utf-8')
|
||||
|
||||
def decode_message(self, msg):
|
||||
# decode_msg needs bytes as input
|
||||
return frappy.client.decode_msg(msg.encode('utf-8'))
|
||||
|
||||
def _getDescribingParameterData(self, module, parameter):
|
||||
return self.modules[module]['parameters'][parameter]
|
||||
|
||||
def updateEvent(self, module, parameter, value, timestamp, readerror):
|
||||
self.newData.emit(module, parameter, Value(value, timestamp, readerror))
|
||||
|
||||
def nodeStateChange(self, online, state):
|
||||
self.stateChange.emit(self.nodename, online, state)
|
||||
|
||||
def unhandledMessage(self, action, specifier, data):
|
||||
self.unhandledMsg.emit('%s %s %r' % (action, specifier, data))
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, hosts, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
loadUi(self, 'mainwindow.ui')
|
||||
|
||||
self.toolBar.hide()
|
||||
|
||||
self.splitter.setStretchFactor(0, 1)
|
||||
self.splitter.setStretchFactor(1, 70)
|
||||
self.splitter.setSizes([50, 500])
|
||||
|
||||
self._nodes = {}
|
||||
self._nodeCtrls = {}
|
||||
self._moduleCtrls = {}
|
||||
self._paramCtrls = {}
|
||||
self._topItems = {}
|
||||
self._currentWidget = self.splitter.widget(1).layout().takeAt(0)
|
||||
|
||||
# add localhost (if available) and SEC nodes given as arguments
|
||||
for host in hosts:
|
||||
try:
|
||||
self._addNode(host)
|
||||
except Exception as e:
|
||||
print(formatExtendedTraceback())
|
||||
print('error in addNode: %r' % e)
|
||||
|
||||
@pyqtSlot()
|
||||
def on_actionAdd_SEC_node_triggered(self):
|
||||
host, ok = QInputDialog.getText(self, 'Add SEC node',
|
||||
'Enter SEC node URI (or just hostname:port):')
|
||||
|
||||
if not ok:
|
||||
return
|
||||
|
||||
try:
|
||||
self._addNode(host)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self.parent(),
|
||||
'Connecting to %s failed!' % host, str(e))
|
||||
|
||||
def on_validateCheckBox_toggled(self, state):
|
||||
print("validateCheckBox_toggled", state)
|
||||
|
||||
def on_visibilityComboBox_activated(self, level):
|
||||
if level in ['user', 'admin', 'expert']:
|
||||
print("visibility Level now:", level)
|
||||
|
||||
def on_treeWidget_currentItemChanged(self, current, previous):
|
||||
if current.type() == ITEM_TYPE_NODE:
|
||||
self._displayNode(current.text(0))
|
||||
elif current.type() == ITEM_TYPE_GROUP:
|
||||
self._displayGroup(current.parent().text(0), current.text(0))
|
||||
elif current.type() == ITEM_TYPE_MODULE:
|
||||
self._displayModule(current.parent().text(0), current.text(0))
|
||||
elif current.type() == ITEM_TYPE_PARAMETER:
|
||||
self._displayParameter(current.parent().parent().text(0),
|
||||
current.parent().text(0), current.text(0))
|
||||
|
||||
def _removeSubTree(self, toplevel_item):
|
||||
self.treeWidget.invisibleRootItem().removeChild(toplevel_item)
|
||||
|
||||
def _set_node_state(self, nodename, online, state):
|
||||
node = self._nodes[nodename]
|
||||
if online:
|
||||
self._topItems[node].setBackground(0, QBrush(QColor('white')))
|
||||
else:
|
||||
self._topItems[node].setBackground(0, QBrush(QColor('orange')))
|
||||
# TODO: make connection state be a separate row
|
||||
node.contactPoint = '%s (%s)' % (node.conn.uri, state)
|
||||
if nodename in self._nodeCtrls:
|
||||
self._nodeCtrls[nodename].contactPointLabel.setText(node.contactPoint)
|
||||
|
||||
def _addNode(self, host):
|
||||
|
||||
# create client
|
||||
node = QSECNode(host, parent=self)
|
||||
nodename = node.nodename
|
||||
|
||||
self._nodes[nodename] = node
|
||||
|
||||
# fill tree
|
||||
nodeItem = QTreeWidgetItem(None, [nodename], 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)
|
||||
paramItem.setDisabled(False)
|
||||
|
||||
self.treeWidget.addTopLevelItem(nodeItem)
|
||||
self._topItems[node] = nodeItem
|
||||
node.stateChange.connect(self._set_node_state)
|
||||
|
||||
def _displayNode(self, node):
|
||||
ctrl = self._nodeCtrls.get(node, None)
|
||||
if ctrl is None:
|
||||
ctrl = self._nodeCtrls[node] = NodeCtrl(self._nodes[node])
|
||||
self._nodes[node].unhandledMsg.connect(ctrl._addLogEntry)
|
||||
|
||||
self._replaceCtrlWidget(ctrl)
|
||||
|
||||
def _displayModule(self, node, module):
|
||||
ctrl = self._moduleCtrls.get((node, module), None)
|
||||
if ctrl is None:
|
||||
ctrl = self._moduleCtrls[(node, module)] = ModuleCtrl(self._nodes[node], module)
|
||||
|
||||
self._replaceCtrlWidget(ctrl)
|
||||
|
||||
def _displayParameter(self, node, module, parameter):
|
||||
ctrl = self._paramCtrls.get((node, module, parameter), None)
|
||||
if ctrl is None:
|
||||
ctrl = ParameterView(self._nodes[node], module, parameter)
|
||||
self._paramCtrls[(node, module, parameter)] = ctrl
|
||||
|
||||
self._replaceCtrlWidget(ctrl)
|
||||
|
||||
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
|
||||
429
frappy/gui/miniplot.py
Normal file
@@ -0,0 +1,429 @@
|
||||
# -*- 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
|
||||
from os import path
|
||||
|
||||
from frappy.gui.qt import QBrush, QColor, QPainter, QPen, \
|
||||
QPointF, QPolygonF, QRectF, QSize, Qt, QWidget
|
||||
|
||||
_magenta = QBrush(QColor('#A12F86'))
|
||||
_yellow = QBrush(QColor('yellow'))
|
||||
_white = QBrush(QColor('white'))
|
||||
_lightgrey = QBrush(QColor('lightgrey'))
|
||||
_grey = QBrush(QColor('grey'))
|
||||
_darkgrey = QBrush(QColor('#404040'))
|
||||
_black = QBrush(QColor('black'))
|
||||
_blue = QBrush(QColor('blue'))
|
||||
_green = QBrush(QColor('green'))
|
||||
_red = QBrush(QColor('red'))
|
||||
_olive = QBrush(QColor('olive'))
|
||||
_orange = QBrush(QColor('#ffa500'))
|
||||
|
||||
|
||||
my_uipath = path.dirname(__file__)
|
||||
|
||||
class MiniPlotCurve:
|
||||
# placeholder for data
|
||||
linecolor = _black
|
||||
linewidth = 0 # set to 0 to disable lines
|
||||
symbolcolors = (_black, _white) # line, fill
|
||||
symbolsize = 3 # both symbol linewidth and symbolsize, set to 0 to disable
|
||||
errorbarcolor = _darkgrey
|
||||
errorbarwidth = 3 # set to 0 to disable errorbar
|
||||
|
||||
def __init__(self):
|
||||
self.data = [] # tripels of x, y, err (err may be None)
|
||||
|
||||
@property
|
||||
def xvalues(self):
|
||||
return [p[0] for p in self.data] if self.data else [0]
|
||||
|
||||
@property
|
||||
def yvalues(self):
|
||||
return [p[1] for p in self.data] if self.data else [0]
|
||||
|
||||
@property
|
||||
def errvalues(self):
|
||||
return [p[2] or 0.0 for p in self.data] if self.data else [0]
|
||||
|
||||
@property
|
||||
def xmin(self):
|
||||
return min(self.xvalues)
|
||||
|
||||
@property
|
||||
def xmax(self):
|
||||
return max(self.xvalues)
|
||||
|
||||
@property
|
||||
def ymin(self):
|
||||
return min(self.yvalues)
|
||||
|
||||
@property
|
||||
def ymax(self):
|
||||
return max(self.yvalues)
|
||||
|
||||
@property
|
||||
def yemin(self):
|
||||
return min(y-(e or 0) for _, y, e in self.data) if self.data else 0
|
||||
|
||||
@property
|
||||
def yemax(self):
|
||||
return max(y+(e or 0) for _, y, e in self.data) if self.data else 0
|
||||
|
||||
|
||||
def paint(self, scale, painter):
|
||||
# note: scale returns a screen-XY tuple for data XY
|
||||
# draw errorbars, lines and symbols in that order
|
||||
if self.errorbarwidth > 0:
|
||||
pen = QPen()
|
||||
pen.setBrush(self.errorbarcolor)
|
||||
pen.setWidth(self.errorbarwidth)
|
||||
painter.setPen(pen)
|
||||
for _x,_y,_e in self.data:
|
||||
if _e is None:
|
||||
continue
|
||||
x, y = scale(_x,_y)
|
||||
e = scale(_x,_y + _e)[1] - y
|
||||
painter.drawLine(x, y-e, x, y+e)
|
||||
painter.fillRect(x - self.errorbarwidth / 2., y - e,
|
||||
self.errorbarwidth, 2 * e, self.errorbarcolor)
|
||||
|
||||
points = [QPointF(*scale(p[0], p[1])) for p in self.data]
|
||||
if self.linewidth > 0:
|
||||
pen = QPen()
|
||||
pen.setBrush(self.linecolor)
|
||||
pen.setWidth(self.linewidth)
|
||||
painter.setPen(pen)
|
||||
painter.drawPolyline(QPolygonF(points))
|
||||
|
||||
if self.symbolsize > 0:
|
||||
pen = QPen()
|
||||
pen.setBrush(self.symbolcolors[0]) # linecolor
|
||||
pen.setWidth(self.symbolsize) # linewidth
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(self.symbolcolors[1]) # fill color
|
||||
if self.symbolsize > 0:
|
||||
for p in points:
|
||||
painter.drawEllipse(p, 2*self.symbolsize, 2*self.symbolsize)
|
||||
|
||||
def preparepainting(self, scale, xmin, xmax):
|
||||
pass # nothing to do
|
||||
|
||||
|
||||
class MiniPlotFitCurve(MiniPlotCurve):
|
||||
|
||||
# do not influence scaling of plotting window
|
||||
@property
|
||||
def xmin(self):
|
||||
return float('inf')
|
||||
|
||||
@property
|
||||
def xmax(self):
|
||||
return float('-inf')
|
||||
|
||||
@property
|
||||
def ymin(self):
|
||||
return float('inf')
|
||||
|
||||
@property
|
||||
def ymax(self):
|
||||
return float('-inf')
|
||||
|
||||
@property
|
||||
def yemin(self):
|
||||
return float('inf')
|
||||
|
||||
@property
|
||||
def yemax(self):
|
||||
return float('-inf')
|
||||
|
||||
def __init__(self, formula, params):
|
||||
super().__init__()
|
||||
self.formula = formula
|
||||
self.params = params
|
||||
|
||||
linecolor = _blue
|
||||
linewidth = 5 # set to 0 to disable lines
|
||||
symbolsize = 0 # both symbol linewidth and symbolsize, set to 0 to disable
|
||||
errorbarwidth = 0 # set to 0 to disable errorbar
|
||||
|
||||
def preparepainting(self, scale, xmin, xmax):
|
||||
# recalculate data
|
||||
points = int(scale(xmax) - scale(xmin))
|
||||
self.data = []
|
||||
for idx in range(points+1):
|
||||
x = xmin + idx * (xmax-xmin) / points
|
||||
y = self.formula(x, *self.params)
|
||||
self.data.append((x,y,None))
|
||||
|
||||
|
||||
class MiniPlot(QWidget):
|
||||
ticklinecolors = (_grey, _lightgrey) # ticks, subticks
|
||||
ticklinewidth = 1
|
||||
bordercolor = _black
|
||||
borderwidth = 1
|
||||
labelcolor = _black
|
||||
xlabel = 'x'
|
||||
ylabel = 'y'
|
||||
xfmt = '%.1f'
|
||||
yfmt = '%g'
|
||||
autotickx = True
|
||||
autoticky = True
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.xmin = self.xmax = None
|
||||
self.ymin = self.ymax = None
|
||||
self.curves = []
|
||||
self.plotx = 0 # left of this are labels
|
||||
self.ploty = self.height() # below this are labels
|
||||
|
||||
def scaleX(self, x):
|
||||
if not self.curves:
|
||||
return x # XXX: !!!!
|
||||
x = self.plotx + (self.width() - self.plotx) * (x - self.xmin) / (self.xmax - self.xmin)
|
||||
# x = max(min(x, self.width()), self.plotx)
|
||||
return x
|
||||
|
||||
def scaleY(self, y):
|
||||
if not self.curves:
|
||||
return y # XXX: !!!!
|
||||
y = self.ploty * (self.ymax - y) / (self.ymax - self.ymin)
|
||||
# y = max(min(y, self.ploty), 0)
|
||||
return y
|
||||
|
||||
def scale(self, x, y):
|
||||
# scales a plotting xx/y to a screen x/y to be used for painting...
|
||||
return self.scaleX(x), self.scaleY(y)
|
||||
|
||||
def removeCurve(self, curve):
|
||||
if curve in self.curves:
|
||||
self.curves.remove(curve)
|
||||
self.updatePlot()
|
||||
|
||||
def addCurve(self, curve):
|
||||
if curve is not None and curve not in self.curves:
|
||||
# new curve, recalculate all
|
||||
self.curves.append(curve)
|
||||
self.updatePlot()
|
||||
|
||||
def updatePlot(self):
|
||||
xmin,xmax = -1,1
|
||||
ymin,ymax = -1,1
|
||||
# find limits of known curves
|
||||
if self.curves:
|
||||
xmin = min(c.xmin for c in self.curves)
|
||||
xmax = max(c.xmax for c in self.curves)
|
||||
ymin = min(c.yemin for c in self.curves)
|
||||
ymax = max(c.yemax for c in self.curves)
|
||||
# fallback values for no curve
|
||||
while xmin >= xmax:
|
||||
xmin, xmax = xmin - 1, xmax + 1
|
||||
while ymin >= ymax:
|
||||
ymin, ymax = ymin - 1, ymax + 1
|
||||
# adjust limits a little
|
||||
self.xmin = xmin - 0.05 * (xmax - xmin)
|
||||
self.xmax = xmax + 0.05 * (xmax - xmin)
|
||||
self.ymin = ymin - 0.05 * (ymax - ymin)
|
||||
self.ymax = ymax + 0.05 * (ymax - ymin)
|
||||
|
||||
# (re-)generate x/yticks
|
||||
if self.autotickx:
|
||||
self.calc_xticks(xmin, xmax)
|
||||
if self. autoticky:
|
||||
self.calc_yticks(ymin, ymax)
|
||||
# redraw
|
||||
self.update()
|
||||
|
||||
def calc_xticks(self, xmin, xmax):
|
||||
self.xticks = self.calc_ticks(xmin, xmax, self.xfmt)
|
||||
|
||||
def calc_yticks(self, ymin, ymax):
|
||||
self.yticks = self.calc_ticks(ymin, ymax, self.yfmt)
|
||||
|
||||
def calc_ticks(self, _min, _max, fmt):
|
||||
min_intervals = 2
|
||||
diff = _max - _min
|
||||
if diff <= 0:
|
||||
return [0]
|
||||
# find a 'good' step size
|
||||
step = abs(diff / min_intervals)
|
||||
# split into mantissa and exp.
|
||||
expo = 0
|
||||
while step >= 10:
|
||||
step /= 10.
|
||||
expo += 1
|
||||
while step < 1:
|
||||
step *= 10.
|
||||
expo -= 1
|
||||
# make step 'latch' into smalle bigger magic number
|
||||
subs = 1
|
||||
for n, subs in reversed([(1,5.), (1.5,3.), (2,4.), (3,3.), (5,5.), (10,2.)]):
|
||||
if step >= n:
|
||||
step = n
|
||||
break
|
||||
# convert back to normal number
|
||||
while expo > 0:
|
||||
step *= 10.
|
||||
expo -= 1
|
||||
while expo < 0:
|
||||
step /= 10.
|
||||
expo += 1
|
||||
substep = step / subs
|
||||
# round lower
|
||||
rounded_min = step * int(_min / step)
|
||||
|
||||
# generate ticks list
|
||||
ticks = []
|
||||
x = rounded_min
|
||||
while x + substep < _min:
|
||||
x += substep
|
||||
for _ in range(100):
|
||||
if x < _max + substep:
|
||||
break
|
||||
|
||||
# check if x is a tick or a subtick
|
||||
x = substep * int(x / substep)
|
||||
if abs(x - step * int(x / step)) <= substep / 2:
|
||||
# tick
|
||||
ticks.append((x, fmt % x))
|
||||
else:
|
||||
# subtick
|
||||
ticks.append((x, ''))
|
||||
x += substep
|
||||
return ticks
|
||||
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
# obtain a few properties we need for proper drawing
|
||||
|
||||
painter.setFont(self.font())
|
||||
fm = painter.fontMetrics()
|
||||
label_height = fm.height()
|
||||
|
||||
self.plotx = 3 + 2 * label_height
|
||||
self.ploty = self.height() - 3 - 2 * label_height
|
||||
|
||||
# fill bg of plotting area
|
||||
painter.fillRect(self.plotx ,0,self.width()-self.plotx, self.ploty,_white)
|
||||
|
||||
# paint ticklines
|
||||
if self.curves and self.ticklinewidth > 0:
|
||||
for e in self.xticks:
|
||||
try:
|
||||
_x = e[0] # pylint: disable=unsubscriptable-object
|
||||
_l = e[1] # pylint: disable=unsubscriptable-object
|
||||
except TypeError:
|
||||
_x = e
|
||||
_l = self.xfmt % _x
|
||||
x = self.scaleX(_x)
|
||||
pen = QPen()
|
||||
pen.setBrush(self.ticklinecolors[0 if _l else 1])
|
||||
pen.setWidth(self.ticklinewidth)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(x, 0, x, self.ploty)
|
||||
for e in self.yticks:
|
||||
try:
|
||||
_y = e[0] # pylint: disable=unsubscriptable-object
|
||||
_l = e[1] # pylint: disable=unsubscriptable-object
|
||||
except TypeError:
|
||||
_y = e
|
||||
_l = self.xfmt % _x
|
||||
y = self.scaleY(_y)
|
||||
pen = QPen()
|
||||
pen.setBrush(self.ticklinecolors[0 if _l else 1])
|
||||
pen.setWidth(self.ticklinewidth)
|
||||
painter.setPen(pen)
|
||||
painter.drawLine(self.plotx, y, self.width(), y)
|
||||
|
||||
# paint curves
|
||||
painter.setClipRect(QRectF(self.plotx, 0, self.width()-self.plotx, self.ploty))
|
||||
for c in self.curves:
|
||||
c.preparepainting(self.scaleX, self.xmin, self.xmax)
|
||||
c.paint(self.scale, painter)
|
||||
painter.setClipping(False)
|
||||
|
||||
# paint frame
|
||||
pen = QPen()
|
||||
pen.setBrush(self.bordercolor)
|
||||
pen.setWidth(self.borderwidth)
|
||||
painter.setPen(pen)
|
||||
painter.drawPolyline(QPolygonF([
|
||||
QPointF(self.plotx, 0),
|
||||
QPointF(self.width()-1, 0),
|
||||
QPointF(self.width()-1, self.ploty),
|
||||
QPointF(self.plotx, self.ploty),
|
||||
QPointF(self.plotx, 0),
|
||||
]))
|
||||
|
||||
# draw labels
|
||||
painter.setBrush(self.labelcolor)
|
||||
h2 = (self.height()-self.ploty)/2.
|
||||
# XXX: offset axis labels from axis a little
|
||||
painter.drawText(self.plotx, self.ploty + h2,
|
||||
self.width() - self.plotx, h2,
|
||||
Qt.AlignCenter | Qt.AlignVCenter, self.xlabel)
|
||||
# rotate ylabel?
|
||||
painter.resetTransform()
|
||||
painter.translate(0, self.ploty / 2.)
|
||||
painter.rotate(-90)
|
||||
w = fm.width(self.ylabel)
|
||||
painter.drawText(-w, -fm.height() / 2., w * 2, self.plotx,
|
||||
Qt.AlignCenter | Qt.AlignTop, self.ylabel)
|
||||
painter.resetTransform()
|
||||
|
||||
if self.curves:
|
||||
for e in self.xticks:
|
||||
try:
|
||||
_x = e[0] # pylint: disable=unsubscriptable-object
|
||||
l = e[1] # pylint: disable=unsubscriptable-object
|
||||
except TypeError:
|
||||
_x = e
|
||||
l = self.xfmt % _x
|
||||
x = self.scaleX(_x)
|
||||
w = fm.width(l)
|
||||
painter.drawText(x - w, self.ploty + 2, 2 * w, h2,
|
||||
Qt.AlignCenter | Qt.AlignVCenter, l)
|
||||
for e in self.yticks:
|
||||
try:
|
||||
_y = e[0] # pylint: disable=unsubscriptable-object
|
||||
l = e[1] # pylint: disable=unsubscriptable-object
|
||||
except TypeError:
|
||||
_y = e
|
||||
l = self.yfmt % _y
|
||||
y = self.scaleY(_y)
|
||||
w = fm.width(l)
|
||||
painter.resetTransform()
|
||||
painter.translate(self.plotx - fm.height(), y + w)
|
||||
painter.rotate(-90)
|
||||
painter.drawText(0, -1,
|
||||
2 * w, fm.height(),
|
||||
Qt.AlignCenter | Qt.AlignBottom, l)
|
||||
painter.resetTransform()
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(320, 240)
|
||||
363
frappy/gui/modulectrl.py
Normal file
@@ -0,0 +1,363 @@
|
||||
# -*- 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 <alexander.lenz@frm2.tum.de>
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
from frappy.gui.params import ParameterView
|
||||
from frappy.gui.qt import QCheckBox, QDialog, QLabel, \
|
||||
QMessageBox, QPushButton, QSizePolicy, QWidget
|
||||
from frappy.gui.util import loadUi
|
||||
from frappy.gui.valuewidgets import get_widget
|
||||
|
||||
|
||||
class CommandDialog(QDialog):
|
||||
def __init__(self, cmdname, argument, parent=None):
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'cmddialog.ui')
|
||||
|
||||
self.setWindowTitle('Arguments for %s' % cmdname)
|
||||
# row = 0
|
||||
|
||||
self._labels = []
|
||||
self.widgets = []
|
||||
# improve! recursive?
|
||||
dtype = argument
|
||||
label = QLabel(repr(dtype))
|
||||
label.setWordWrap(True)
|
||||
widget = get_widget(dtype, readonly=False)
|
||||
self.gridLayout.addWidget(label, 0, 0)
|
||||
self.gridLayout.addWidget(widget, 0, 1)
|
||||
self._labels.append(label)
|
||||
self.widgets.append(widget)
|
||||
|
||||
self.gridLayout.setRowStretch(1, 1)
|
||||
self.setModal(True)
|
||||
self.resize(self.sizeHint())
|
||||
|
||||
def get_value(self):
|
||||
return True, self.widgets[0].get_value()
|
||||
|
||||
def exec_(self):
|
||||
if super().exec_():
|
||||
return self.get_value()
|
||||
return None
|
||||
|
||||
|
||||
def showCommandResultDialog(command, args, result, extras=''):
|
||||
m = QMessageBox()
|
||||
args = '' if args is None else repr(args)
|
||||
m.setText('calling: %s(%s)\nyielded: %r\nqualifiers: %s' %
|
||||
(command, args, result, extras))
|
||||
m.exec_()
|
||||
|
||||
|
||||
def showErrorDialog(command, args, error):
|
||||
m = QMessageBox()
|
||||
args = '' if args is None else repr(args)
|
||||
m.setText('calling: %s(%s)\nraised %r' % (command, args, error))
|
||||
m.exec_()
|
||||
|
||||
|
||||
class ParameterGroup(QWidget):
|
||||
|
||||
def __init__(self, groupname, parent=None):
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'paramgroup.ui')
|
||||
|
||||
self._groupname = groupname
|
||||
|
||||
self._row = 0
|
||||
self._widgets = []
|
||||
|
||||
self.paramGroupBox.setTitle('Group: ' + str(groupname))
|
||||
self.paramGroupBox.toggled.connect(self.on_toggle_clicked)
|
||||
self.paramGroupBox.setChecked(False)
|
||||
|
||||
def addWidgets(self, label, widget):
|
||||
self._widgets.extend((label, widget))
|
||||
self.paramGroupBox.layout().addWidget(label, self._row, 0)
|
||||
self.paramGroupBox.layout().addWidget(widget, self._row, 1)
|
||||
label.hide()
|
||||
widget.hide()
|
||||
self._row += 1
|
||||
|
||||
def on_toggle_clicked(self):
|
||||
if self.paramGroupBox.isChecked():
|
||||
for w in self._widgets:
|
||||
w.show()
|
||||
else:
|
||||
for w in self._widgets:
|
||||
w.hide()
|
||||
|
||||
|
||||
class CommandButton(QPushButton):
|
||||
|
||||
def __init__(self, cmdname, cmdinfo, cb, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._cmdname = cmdname
|
||||
self._argintype = cmdinfo['datatype'].argument # single datatype
|
||||
self.result = cmdinfo['datatype'].result
|
||||
self._cb = cb # callback function for exection
|
||||
|
||||
self.setText(cmdname)
|
||||
if cmdinfo['description']:
|
||||
self.setToolTip(cmdinfo['description'])
|
||||
self.pressed.connect(self.on_pushButton_pressed)
|
||||
|
||||
def on_pushButton_pressed(self):
|
||||
self.setEnabled(False)
|
||||
if self._argintype:
|
||||
dlg = CommandDialog(self._cmdname, self._argintype)
|
||||
args = dlg.exec_()
|
||||
if args: # not 'Cancel' clicked
|
||||
self._cb(self._cmdname, args[1])
|
||||
else:
|
||||
# no need for arguments
|
||||
self._cb(self._cmdname, None)
|
||||
self.setEnabled(True)
|
||||
|
||||
|
||||
class ModuleCtrl(QWidget):
|
||||
|
||||
def __init__(self, node, module, parent=None):
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'modulectrl.ui')
|
||||
self._node = node
|
||||
self._module = module
|
||||
self._lastclick = None
|
||||
|
||||
self._paramWidgets = {} # widget cache do avoid garbage collection
|
||||
self._groupWidgets = {} # cache of grouping widgets
|
||||
|
||||
self._labelfont = self.font()
|
||||
self._labelfont.setBold(True)
|
||||
|
||||
self.moduleNameLabel.setText(module)
|
||||
self._initModuleWidgets()
|
||||
|
||||
self._node.newData.connect(self._updateValue)
|
||||
|
||||
def _execCommand(self, command, args=None):
|
||||
try:
|
||||
result, qualifiers = self._node.execCommand(
|
||||
self._module, command, args)
|
||||
except Exception as e:
|
||||
showErrorDialog(command, args, e)
|
||||
return
|
||||
if result is not None:
|
||||
showCommandResultDialog(command, args, result, qualifiers)
|
||||
|
||||
def _initModuleWidgets(self):
|
||||
initValues = self._node.queryCache(self._module)
|
||||
row = 0
|
||||
|
||||
# ignore groupings for commands (for now)
|
||||
commands = self._node.getCommands(self._module)
|
||||
# keep a reference or the widgets are destroyed to soon.
|
||||
self.cmdWidgets = cmdWidgets = {}
|
||||
# create and insert widgets into our QGridLayout
|
||||
for command in sorted(commands):
|
||||
# XXX: fetch and use correct datatypes here!
|
||||
w = CommandButton(command, commands[command], self._execCommand)
|
||||
cmdWidgets[command] = w
|
||||
self.commandGroupBox.layout().addWidget(w, 0, row)
|
||||
row += 1
|
||||
|
||||
row = 0
|
||||
# collect grouping information
|
||||
paramsByGroup = {} # groupname -> [paramnames]
|
||||
allGroups = set()
|
||||
params = self._node.getParameters(self._module)
|
||||
for param in params:
|
||||
props = self._node.getProperties(self._module, param)
|
||||
group = props.get('group', '')
|
||||
if group:
|
||||
allGroups.add(group)
|
||||
paramsByGroup.setdefault(group, []).append(param)
|
||||
# enforce reading initial value if not already in cache
|
||||
if param not in initValues:
|
||||
self._node.getParameter(self._module, param)
|
||||
|
||||
# groupname -> CheckBoxWidget for (un)folding
|
||||
self._groupWidgets = groupWidgets = {}
|
||||
|
||||
# create and insert widgets into our QGridLayout
|
||||
# iterate over a union of all groups and all params
|
||||
for param in sorted(allGroups.union(set(params))):
|
||||
labelstr = param + ':'
|
||||
if param in paramsByGroup:
|
||||
group = param
|
||||
# is the name of a group -> create (un)foldable label
|
||||
checkbox = QCheckBox(labelstr)
|
||||
checkbox.setFont(self._labelfont)
|
||||
groupWidgets[param] = checkbox
|
||||
|
||||
# check if there is a param of the same name too
|
||||
if group in params:
|
||||
datatype = self._node.getProperties(
|
||||
self._module, group).get(
|
||||
'datatype', None)
|
||||
# yes: create a widget for this as well
|
||||
labelstr, buttons = self._makeEntry(
|
||||
group, initValues[param], datatype=datatype, nolabel=True, checkbox=checkbox, invert=True)
|
||||
checkbox.setText(labelstr)
|
||||
|
||||
# add to Layout (yes: ignore the label!)
|
||||
self.paramGroupBox.layout().addWidget(checkbox, row, 0)
|
||||
self.paramGroupBox.layout().addWidget(buttons, row, 1)
|
||||
else:
|
||||
self.paramGroupBox.layout().addWidget(checkbox, row, 0, 1, 2) # or .. 1, 2) ??
|
||||
row += 1
|
||||
|
||||
# loop over all params and insert and connect
|
||||
for param_ in paramsByGroup[param]:
|
||||
if param_ == group:
|
||||
continue
|
||||
if param_ not in initValues:
|
||||
initval = None
|
||||
print("Warning: %r not in initValues!" % param_)
|
||||
else:
|
||||
initval = initValues[param_]
|
||||
datatype = self._node.getProperties(
|
||||
self._module, param_).get(
|
||||
'datatype', None)
|
||||
label, buttons = self._makeEntry(
|
||||
param_, initval, datatype=datatype, checkbox=checkbox, invert=False)
|
||||
|
||||
# add to Layout
|
||||
self.paramGroupBox.layout().addWidget(label, row, 0)
|
||||
self.paramGroupBox.layout().addWidget(buttons, row, 1)
|
||||
row += 1
|
||||
|
||||
else:
|
||||
# param is a 'normal' param: create a widget if it has no group
|
||||
# or is named after a group (otherwise its created above)
|
||||
props = self._node.getProperties(self._module, param)
|
||||
if (props.get('group', '') or param) == param:
|
||||
datatype = self._node.getProperties(
|
||||
self._module, param).get(
|
||||
'datatype', None)
|
||||
label, buttons = self._makeEntry(
|
||||
param, initValues[param], datatype=datatype)
|
||||
|
||||
# add to Layout
|
||||
self.paramGroupBox.layout().addWidget(label, row, 0)
|
||||
self.paramGroupBox.layout().addWidget(buttons, row, 1)
|
||||
row += 1
|
||||
|
||||
# also populate properties
|
||||
self._propWidgets = {}
|
||||
props = self._node.getModuleProperties(self._module)
|
||||
row = 0
|
||||
for prop in sorted(props):
|
||||
label = QLabel(prop + ':')
|
||||
label.setFont(self._labelfont)
|
||||
label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
|
||||
|
||||
# make 'display' label
|
||||
view = QLabel(str(props[prop]))
|
||||
view.setFont(self.font())
|
||||
view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
view.setWordWrap(True)
|
||||
|
||||
self.propertyGroupBox.layout().addWidget(label, row, 0)
|
||||
self.propertyGroupBox.layout().addWidget(view, row, 1)
|
||||
row += 1
|
||||
|
||||
self._propWidgets[prop] = (label, view)
|
||||
|
||||
def _makeEntry(
|
||||
self,
|
||||
param,
|
||||
initvalue,
|
||||
datatype=None,
|
||||
nolabel=False,
|
||||
checkbox=None,
|
||||
invert=False):
|
||||
props = self._node.getProperties(self._module, param)
|
||||
|
||||
description = props.get('description', '')
|
||||
unit = props.get('unit', '')
|
||||
|
||||
if unit:
|
||||
labelstr = '%s (%s):' % (param, unit)
|
||||
else:
|
||||
labelstr = '%s:' % (param,)
|
||||
|
||||
if checkbox and not invert:
|
||||
labelstr = ' ' + labelstr
|
||||
|
||||
buttons = ParameterView(
|
||||
self._module,
|
||||
param,
|
||||
datatype=datatype,
|
||||
initvalue=initvalue,
|
||||
readonly=props['readonly'])
|
||||
buttons.setRequested.connect(self._set_Button_pressed)
|
||||
|
||||
if description:
|
||||
buttons.setToolTip(description)
|
||||
|
||||
if nolabel:
|
||||
label = labelstr
|
||||
else:
|
||||
label = QLabel(labelstr)
|
||||
label.setFont(self._labelfont)
|
||||
|
||||
if checkbox:
|
||||
def stateChanged(
|
||||
newstate,
|
||||
buttons=buttons,
|
||||
label=None if nolabel else label,
|
||||
invert=invert):
|
||||
if (newstate and not invert) or (invert and not newstate):
|
||||
buttons.show()
|
||||
if label:
|
||||
label.show()
|
||||
else:
|
||||
buttons.hide()
|
||||
if label:
|
||||
label.hide()
|
||||
# set initial state
|
||||
stateChanged(0)
|
||||
# connect
|
||||
checkbox.stateChanged.connect(stateChanged)
|
||||
|
||||
self._paramWidgets[param] = (label, buttons)
|
||||
|
||||
return label, buttons
|
||||
|
||||
def _set_Button_pressed(self, module, parameter, target):
|
||||
try:
|
||||
self._node.setParameter(module, parameter, target)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self.parent(), 'Operation failed', str(e))
|
||||
|
||||
def _updateValue(self, module, parameter, value):
|
||||
if module != self._module:
|
||||
return
|
||||
# value is is type frappy.gui.mainwindow.Value
|
||||
# note: update subwidgets with the data portion only
|
||||
# note: paramwidgets[..][1] is a ParameterView from frappy.gui.params
|
||||
self._paramWidgets[parameter][1].updateValue(value)
|
||||
331
frappy/gui/nodectrl.py
Normal file
@@ -0,0 +1,331 @@
|
||||
# -*- 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 <alexander.lenz@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
import json
|
||||
import pprint
|
||||
from time import sleep
|
||||
|
||||
import mlzlog
|
||||
|
||||
import frappy.lib
|
||||
from frappy.datatypes import EnumType, StringType
|
||||
from frappy.errors import SECoPError
|
||||
from frappy.gui.qt import QFont, QFontMetrics, QLabel, \
|
||||
QMessageBox, QTextCursor, QWidget, pyqtSlot, toHtmlEscaped
|
||||
from frappy.gui.util import Value, loadUi
|
||||
|
||||
|
||||
class NodeCtrl(QWidget):
|
||||
|
||||
def __init__(self, node, parent=None):
|
||||
super().__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.nodeDescriptionLabel.setText(self._node.properties.get('description',
|
||||
'no description available'))
|
||||
self._clearLog()
|
||||
|
||||
# now populate modules tab
|
||||
self._init_modules_tab()
|
||||
|
||||
node.logEntry.connect(self._addLogEntry)
|
||||
|
||||
@pyqtSlot()
|
||||
def on_sendPushButton_clicked(self):
|
||||
msg = self.msgLineEdit.text().strip()
|
||||
|
||||
if not msg:
|
||||
return
|
||||
|
||||
self._addLogEntry(
|
||||
'<span style="font-weight:bold">Request:</span> '
|
||||
'%s:' % msg,
|
||||
raw=True)
|
||||
# msg = msg.split(' ', 2)
|
||||
try:
|
||||
reply = self._node.syncCommunicate(*self._node.decode_message(msg))
|
||||
if msg == 'describe':
|
||||
_, eid, stuff = self._node.decode_message(reply)
|
||||
reply = '%s %s %s' % (_, eid, json.dumps(
|
||||
stuff, indent=2, separators=(',', ':'), sort_keys=True))
|
||||
self._addLogEntry(reply, newline=True, pretty=False)
|
||||
else:
|
||||
self._addLogEntry(reply, newline=True, pretty=False)
|
||||
except SECoPError as e:
|
||||
einfo = e.args[0] if len(e.args) == 1 else json.dumps(e.args)
|
||||
self._addLogEntry(
|
||||
'%s: %s' % (e.name, einfo),
|
||||
newline=True,
|
||||
pretty=False,
|
||||
error=True)
|
||||
except Exception as e:
|
||||
self._addLogEntry(
|
||||
'error when sending %r: %r' % (msg, e),
|
||||
newline=True,
|
||||
pretty=False,
|
||||
error=True)
|
||||
|
||||
@pyqtSlot()
|
||||
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,
|
||||
error=False):
|
||||
if pretty:
|
||||
msg = pprint.pformat(msg, width=self._getLogWidth())
|
||||
msg = msg[1:-1]
|
||||
|
||||
if not raw:
|
||||
if error:
|
||||
msg = '<div style="color:#FF0000"><b><pre>%s</pre></b></div>' % toHtmlEscaped(
|
||||
str(msg)).replace('\n', '<br />')
|
||||
else:
|
||||
msg = '<pre>%s</pre>' % toHtmlEscaped(
|
||||
str(msg)).replace('\n', '<br />')
|
||||
|
||||
content = ''
|
||||
if self.logTextBrowser.toPlainText():
|
||||
content = self.logTextBrowser.toHtml()
|
||||
content += msg
|
||||
|
||||
if newline:
|
||||
content += '<br />'
|
||||
|
||||
self.logTextBrowser.setHtml(content)
|
||||
self.logTextBrowser.moveCursor(QTextCursor.End)
|
||||
|
||||
def _getLogWidth(self):
|
||||
fontMetrics = QFontMetrics(QFont('Monospace'))
|
||||
# calculate max avail characters by using an m (which is possible
|
||||
# due to monospace)
|
||||
result = self.logTextBrowser.width() / fontMetrics.width('m')
|
||||
return result
|
||||
|
||||
def _init_modules_tab(self):
|
||||
self._moduleWidgets = []
|
||||
layout = self.scrollAreaWidgetContents.layout()
|
||||
labelfont = self.font()
|
||||
labelfont.setBold(True)
|
||||
row = 0
|
||||
for modname in sorted(self._node.modules):
|
||||
modprops = self._node.getModuleProperties(modname)
|
||||
interfaces = modprops.get('interface_classes', '')
|
||||
description = modprops.get('description', '!!! missing description !!!')
|
||||
|
||||
# fallback: allow (now) invalid 'Driveable'
|
||||
unit = ''
|
||||
try:
|
||||
if 'Drivable' in interfaces or 'Driveable' in interfaces:
|
||||
widget = DrivableWidget(self._node, modname, self)
|
||||
unit = self._node.getProperties(modname, 'value').get('unit', '')
|
||||
elif 'Writable' in interfaces or 'Writeable' in interfaces:
|
||||
# XXX !!!
|
||||
widget = DrivableWidget(self._node, modname, self)
|
||||
unit = self._node.getProperties(modname, 'value').get('unit', '')
|
||||
elif 'Readable' in interfaces:
|
||||
widget = ReadableWidget(self._node, modname, self)
|
||||
unit = self._node.getProperties(modname, 'value').get('unit', '')
|
||||
else:
|
||||
widget = QLabel('Unsupported Interfaceclass %r' % interfaces)
|
||||
except Exception as e:
|
||||
print(frappy.lib.formatExtendedTraceback())
|
||||
widget = QLabel('Bad configured Module %s! (%s)' % (modname, e))
|
||||
|
||||
if unit:
|
||||
labelstr = '%s (%s):' % (modname, unit)
|
||||
else:
|
||||
labelstr = '%s:' % (modname,)
|
||||
label = QLabel(labelstr)
|
||||
label.setFont(labelfont)
|
||||
|
||||
if description:
|
||||
widget.setToolTip(description)
|
||||
|
||||
layout.addWidget(label, row, 0)
|
||||
layout.addWidget(widget, row, 1)
|
||||
|
||||
row += 1
|
||||
self._moduleWidgets.extend((label, widget))
|
||||
layout.setRowStretch(row, 1)
|
||||
|
||||
|
||||
class ReadableWidget(QWidget):
|
||||
|
||||
def __init__(self, node, module, parent=None):
|
||||
super().__init__(parent)
|
||||
self._node = node
|
||||
self._module = module
|
||||
|
||||
# XXX: avoid a nasty race condition, mainly biting on M$
|
||||
for i in range(15):
|
||||
if 'status' in self._node.modules[module]['parameters']:
|
||||
break
|
||||
sleep(0.01*i)
|
||||
|
||||
self._status_type = self._node.getProperties(
|
||||
self._module, 'status').get('datatype')
|
||||
|
||||
try:
|
||||
props = self._node.getProperties(self._module, 'target')
|
||||
datatype = props.get('datatype', StringType())
|
||||
self._is_enum = isinstance(datatype, EnumType)
|
||||
except KeyError:
|
||||
self._is_enum = False
|
||||
|
||||
loadUi(self, 'modulebuttons.ui')
|
||||
|
||||
# populate comboBox, keeping a mapping of Qt-index to EnumValue
|
||||
if self._is_enum:
|
||||
self._map = {} # maps QT-idx to name/value
|
||||
self._revmap = {} # maps value/name to QT-idx
|
||||
for idx, member in enumerate(datatype._enum.members):
|
||||
self._map[idx] = member
|
||||
self._revmap[member.name] = idx
|
||||
self._revmap[member.value] = idx
|
||||
self.targetComboBox.addItem(member.name, member.value)
|
||||
|
||||
self._init_status_widgets()
|
||||
self._init_current_widgets()
|
||||
self._init_target_widgets()
|
||||
|
||||
self._node.newData.connect(self._updateValue)
|
||||
|
||||
def _get(self, pname, fallback=Ellipsis):
|
||||
try:
|
||||
return Value(*self._node.getParameter(self._module, pname))
|
||||
except Exception as e:
|
||||
# happens only, if there is no response form read request
|
||||
mlzlog.getLogger('cached values').warn(
|
||||
'no cached value for %s:%s %r' % (self._module, pname, e))
|
||||
return Value(fallback)
|
||||
|
||||
def _init_status_widgets(self):
|
||||
self.update_status(self._get('status', (400, '<not supported>')))
|
||||
# XXX: also connect update_status signal to LineEdit ??
|
||||
|
||||
def update_status(self, status):
|
||||
self.statusLineEdit.setText(str(status))
|
||||
# may change meaning of cmdPushButton
|
||||
|
||||
def _init_current_widgets(self):
|
||||
self.update_current(self._get('value', ''))
|
||||
|
||||
def update_current(self, value):
|
||||
self.currentLineEdit.setText(str(value))
|
||||
|
||||
def _init_target_widgets(self):
|
||||
# Readable has no target: disable widgets
|
||||
self.targetLineEdit.setHidden(True)
|
||||
self.targetComboBox.setHidden(True)
|
||||
self.cmdPushButton.setHidden(True)
|
||||
|
||||
def update_target(self, target):
|
||||
pass
|
||||
|
||||
def _updateValue(self, module, parameter, value):
|
||||
if module != self._module:
|
||||
return
|
||||
if parameter == 'status':
|
||||
self.update_status(value)
|
||||
elif parameter == 'value':
|
||||
self.update_current(value)
|
||||
elif parameter == 'target':
|
||||
self.update_target(value)
|
||||
|
||||
|
||||
class DrivableWidget(ReadableWidget):
|
||||
|
||||
def _init_target_widgets(self):
|
||||
if self._is_enum:
|
||||
# EnumType: disable Linedit
|
||||
self.targetLineEdit.setHidden(True)
|
||||
self.cmdPushButton.setHidden(True)
|
||||
else:
|
||||
# normal types: disable Combobox
|
||||
self.targetComboBox.setHidden(True)
|
||||
target = self._get('target', None)
|
||||
if target.value is not None:
|
||||
if isinstance(target.value, list) and isinstance(target.value[1], dict):
|
||||
self.update_target(Value(target.value[0]))
|
||||
else:
|
||||
self.update_target(target)
|
||||
|
||||
def update_current(self, value):
|
||||
self.currentLineEdit.setText(str(value))
|
||||
# elif self._is_enum:
|
||||
# member = self._map[self._revmap[value.value]]
|
||||
# self.currentLineEdit.setText('%s.%s (%d)' % (member.enum.name, member.name, member.value))
|
||||
|
||||
def update_target(self, target):
|
||||
if self._is_enum:
|
||||
if target.readerror:
|
||||
return
|
||||
# update selected item
|
||||
value = target.value
|
||||
if value in self._revmap:
|
||||
self.targetComboBox.setCurrentIndex(self._revmap[value])
|
||||
else:
|
||||
print(
|
||||
"%s: Got invalid target value %r!" %
|
||||
(self._module, value))
|
||||
else:
|
||||
self.targetLineEdit.setText(str(target))
|
||||
|
||||
def target_go(self, target):
|
||||
try:
|
||||
self._node.setParameter(self._module, 'target', target)
|
||||
except Exception as e:
|
||||
self._node.log.exception(e)
|
||||
QMessageBox.warning(self.parent(), 'Operation failed', str(e))
|
||||
|
||||
@pyqtSlot()
|
||||
def on_cmdPushButton_clicked(self):
|
||||
if self._is_enum:
|
||||
self.on_targetComboBox_activated(self.targetComboBox.currentText())
|
||||
else:
|
||||
self.on_targetLineEdit_returnPressed()
|
||||
|
||||
@pyqtSlot()
|
||||
def on_targetLineEdit_returnPressed(self):
|
||||
self.target_go(self.targetLineEdit.text())
|
||||
|
||||
@pyqtSlot(str)
|
||||
def on_targetComboBox_activated(self, selection):
|
||||
self.target_go(selection)
|
||||
180
frappy/gui/params/__init__.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# -*- 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
from frappy.datatypes import EnumType
|
||||
from frappy.gui.qt import QWidget, pyqtSignal, pyqtSlot
|
||||
from frappy.gui.util import loadUi
|
||||
|
||||
|
||||
class ParameterWidget(QWidget):
|
||||
setRequested = pyqtSignal(str, str, object) # module, parameter, target
|
||||
plotRequested = pyqtSignal(str, str) # module, parameter
|
||||
cmdRequested = pyqtSignal(str, str, list) # module, command, args
|
||||
|
||||
def __init__(self,
|
||||
module,
|
||||
paramcmd,
|
||||
datatype=None,
|
||||
initvalue=None,
|
||||
readonly=True,
|
||||
parent=None):
|
||||
super().__init__(parent)
|
||||
self._module = module
|
||||
self._paramcmd = paramcmd
|
||||
self._datatype = datatype
|
||||
self._readonly = readonly
|
||||
|
||||
self._load_ui(initvalue)
|
||||
|
||||
def _load_ui(self, initvalue):
|
||||
# load ui file, set initvalue to right widget
|
||||
pass
|
||||
|
||||
def updateValue(self, value):
|
||||
# async !
|
||||
pass
|
||||
|
||||
|
||||
class GenericParameterWidget(ParameterWidget):
|
||||
|
||||
def _load_ui(self, initvalue):
|
||||
# using two QLineEdits for current and target value
|
||||
loadUi(self, 'parambuttons.ui')
|
||||
|
||||
if self._readonly:
|
||||
self.setPushButton.setEnabled(False)
|
||||
self.setLineEdit.setEnabled(False)
|
||||
else:
|
||||
self.setLineEdit.returnPressed.connect(
|
||||
self.on_setPushButton_clicked)
|
||||
self.updateValue(initvalue)
|
||||
|
||||
@pyqtSlot()
|
||||
def on_setPushButton_clicked(self):
|
||||
self.setRequested.emit(self._module, self._paramcmd,
|
||||
self.setLineEdit.text())
|
||||
|
||||
def updateValue(self, value):
|
||||
fmtstr = getattr(self._datatype, 'fmtstr', '%s')
|
||||
if value.readerror:
|
||||
value = str(value)
|
||||
else:
|
||||
value = fmtstr % (value.value,)
|
||||
self.currentLineEdit.setText(value)
|
||||
|
||||
|
||||
class EnumParameterWidget(GenericParameterWidget):
|
||||
|
||||
def _load_ui(self, initvalue):
|
||||
# using two QLineEdits for current and target value
|
||||
loadUi(self, 'parambuttons_select.ui')
|
||||
|
||||
# transfer allowed settings from datatype to comboBoxes
|
||||
self._map = {} # maps index to EnumMember
|
||||
self._revmap = {} # maps Enum.name + Enum.value to index
|
||||
for index, member in enumerate(self._datatype._enum.members):
|
||||
self.setComboBox.addItem(member.name, member.value)
|
||||
self._map[index] = member
|
||||
self._revmap[member.name] = self._revmap[member.value] = index
|
||||
if self._readonly:
|
||||
self.setLabel.setEnabled(False)
|
||||
self.setComboBox.setEnabled(False)
|
||||
self.setLabel.setHidden(True)
|
||||
self.setComboBox.setHidden(True)
|
||||
else:
|
||||
self.setComboBox.activated.connect(self.on_setPushButton_clicked)
|
||||
|
||||
self.updateValue(initvalue)
|
||||
|
||||
@pyqtSlot()
|
||||
def on_setPushButton_clicked(self):
|
||||
member = self._map[self.setComboBox.currentIndex()]
|
||||
self.setRequested.emit(self._module, self._paramcmd, member)
|
||||
|
||||
def updateValue(self, value):
|
||||
self.currentLineEdit.setText(str(value))
|
||||
|
||||
|
||||
class GenericCmdWidget(ParameterWidget):
|
||||
|
||||
def _load_ui(self, initvalue):
|
||||
# using two QLineEdits for current and target value
|
||||
loadUi(self, 'cmdbuttons.ui')
|
||||
|
||||
self.cmdLineEdit.setText('')
|
||||
self.cmdLineEdit.setEnabled(self.datatype.argument is not None)
|
||||
self.cmdLineEdit.returnPressed.connect(
|
||||
self.on_cmdPushButton_clicked)
|
||||
|
||||
@pyqtSlot()
|
||||
def on_cmdPushButton_clicked(self):
|
||||
# wait until command complete before retrying
|
||||
# since the command is scheduled async: what if an errot happens?
|
||||
# XXX: button stays deactivated upon errors in execution of cmd...
|
||||
self.cmdPushButton.setEnabled(False)
|
||||
self.cmdRequested.emit(
|
||||
self._module,
|
||||
self._paramcmd,
|
||||
self._datatype.from_string(
|
||||
self.cmdLineEdit.text()))
|
||||
|
||||
def updateValue(self, value):
|
||||
# open dialog and show value, if any.
|
||||
# then re-activate the command button
|
||||
self.cmdPushButton.setEnabled(True)
|
||||
|
||||
|
||||
def ParameterView(module,
|
||||
paramcmd,
|
||||
datatype=None,
|
||||
initvalue=None,
|
||||
readonly=True,
|
||||
parent=None):
|
||||
# depending on datatype returns an initialized widget fit for display and
|
||||
# interaction
|
||||
if datatype is not None:
|
||||
if datatype.IS_COMMAND:
|
||||
return GenericCmdWidget(
|
||||
module,
|
||||
paramcmd, # name of command
|
||||
datatype,
|
||||
initvalue, # not used for comands
|
||||
readonly, # not used for commands
|
||||
parent)
|
||||
if isinstance(datatype, EnumType):
|
||||
return EnumParameterWidget(
|
||||
module,
|
||||
paramcmd, # name of parameter
|
||||
datatype,
|
||||
initvalue,
|
||||
readonly,
|
||||
parent)
|
||||
|
||||
return GenericParameterWidget(
|
||||
module,
|
||||
paramcmd, # name of parameter
|
||||
datatype,
|
||||
initvalue,
|
||||
readonly,
|
||||
parent)
|
||||
76
frappy/gui/paramview.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
# Copyright (c) 2015-2017 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
from frappy.gui.qt import QLabel, QSizePolicy, QWidget
|
||||
from frappy.gui.util import loadUi
|
||||
|
||||
|
||||
class ParameterView(QWidget):
|
||||
|
||||
def __init__(self, node, module, parameter, parent=None):
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'paramview.ui')
|
||||
self._node = node
|
||||
self._module = module
|
||||
self._parameter = parameter
|
||||
|
||||
self._propWidgets = {} # widget cache do avoid garbage collection
|
||||
|
||||
self.paramNameLabel.setText("%s:%s" % (module, parameter))
|
||||
self._initParameterWidgets()
|
||||
|
||||
# self._node.newData.connect(self._updateValue)
|
||||
|
||||
def _initParameterWidgets(self):
|
||||
# initValues = self._node.queryCache(self._module) #? mix live data?
|
||||
row = 0
|
||||
|
||||
font = self.font()
|
||||
font.setBold(True)
|
||||
|
||||
props = self._node._getDescribingParameterData(self._module,
|
||||
self._parameter)
|
||||
for prop in sorted(props):
|
||||
label = QLabel(prop + ':')
|
||||
label.setFont(font)
|
||||
label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
|
||||
|
||||
# make 'display' label
|
||||
view = QLabel(str(props[prop]))
|
||||
view.setFont(self.font())
|
||||
view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
view.setWordWrap(True)
|
||||
|
||||
self.propertyGroupBox.layout().addWidget(label, row, 0)
|
||||
self.propertyGroupBox.layout().addWidget(view, row, 1)
|
||||
|
||||
self._propWidgets[prop] = (label, view)
|
||||
|
||||
row += 1
|
||||
|
||||
def _updateValue(self, module, parameter, value):
|
||||
if module != self._module:
|
||||
return
|
||||
|
||||
self._paramWidgets[parameter][1].currentLineEdit.setText(str(value[0]))
|
||||
63
frappy/gui/qt.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Import needed stuff from PyQt4/PyQt5"""
|
||||
|
||||
# pylint: disable=unused-import
|
||||
|
||||
import sys
|
||||
|
||||
try:
|
||||
# Do not abort on exceptions in signal handlers.
|
||||
# pylint: disable=unnecessary-lambda
|
||||
sys.excepthook = lambda *args: sys.__excepthook__(*args)
|
||||
|
||||
from PyQt5 import uic
|
||||
from PyQt5.QtCore import Qt, QObject, pyqtSignal, pyqtSlot, QSize, QPointF, \
|
||||
QRectF, QPoint
|
||||
from PyQt5.QtGui import QFont, QTextCursor, QFontMetrics, QColor, QBrush, \
|
||||
QPainter, QPolygonF, QPen, QIcon, QStandardItemModel, QStandardItem
|
||||
from PyQt5.QtWidgets import QLabel, QWidget, QDialog, QLineEdit, QCheckBox, \
|
||||
QPushButton, QSizePolicy, QMainWindow, QMessageBox, QInputDialog, \
|
||||
QTreeWidgetItem, QApplication, QGroupBox, QSpinBox, QDoubleSpinBox, \
|
||||
QComboBox, QRadioButton, QVBoxLayout, QHBoxLayout, QGridLayout, \
|
||||
QScrollArea, QFrame, QTreeWidget, QFileDialog, QTabBar, QAction, QMenu,\
|
||||
QDialogButtonBox, QTextEdit, QAbstractItemView, QSpacerItem, QTreeView
|
||||
|
||||
from xml.sax.saxutils import escape as toHtmlEscaped
|
||||
|
||||
import frappy.gui.icon_rc_qt5
|
||||
|
||||
except ImportError:
|
||||
from PyQt4 import uic
|
||||
from PyQt4.QtCore import Qt, QObject, pyqtSignal, pyqtSlot, QSize, QPointF, QRectF, QPoint
|
||||
from PyQt4.QtGui import QFont, QTextCursor, QFontMetrics, \
|
||||
QLabel, QWidget, QDialog, QLineEdit, QCheckBox, QPushButton, QTextEdit,\
|
||||
QSizePolicy, QMainWindow, QMessageBox, QInputDialog, QTreeWidgetItem, QApplication, \
|
||||
QGroupBox, QSpinBox, QDoubleSpinBox, QComboBox, QRadioButton, QVBoxLayout, QHBoxLayout, \
|
||||
QGridLayout, QScrollArea, QFrame, QColor, QBrush, QPainter, QPolygonF, QPen, QIcon, \
|
||||
QTreeWidget, QFileDialog, QTabBar, QAction, QMenu, QDialogButtonBox, QAbstractItemView, \
|
||||
QSpacerItem, QTreeView, QStandardItemModel, QStandardItem
|
||||
|
||||
import frappy.gui.icon_rc_qt4
|
||||
|
||||
def toHtmlEscaped(s):
|
||||
return Qt.escape(s)
|
||||
60
frappy/gui/ui/cmdbuttons.ui
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>730</width>
|
||||
<height>33</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="horizontalSpacing">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="verticalSpacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="margin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Arguments:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="cmdLineEdit">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>256</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="cmdPushButton">
|
||||
<property name="text">
|
||||
<string>Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
88
frappy/gui/ui/cmddialog.ui
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Dialog</class>
|
||||
<widget class="QDialog" name="Dialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>262</width>
|
||||
<height>135</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Dialog</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QScrollArea" name="scrollArea">
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="scrollAreaWidgetContents">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>242</width>
|
||||
<height>76</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout"/>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>Dialog</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>248</x>
|
||||
<y>254</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>Dialog</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>316</x>
|
||||
<y>260</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
187
frappy/gui/ui/mainwindow.ui
Normal file
@@ -0,0 +1,187 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>MainWindow</class>
|
||||
<widget class="QMainWindow" name="MainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1228</width>
|
||||
<height>600</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>frappy-gui</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QWidget" name="layoutWidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QComboBox" name="visibilityComboBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>user</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>admin</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>expert</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="validateCheckBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Validate locally</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="treeWidget">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<attribute name="headerVisible">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string notr="true">1</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="widget" native="true">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2"/>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1228</width>
|
||||
<height>33</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
<property name="title">
|
||||
<string>File</string>
|
||||
</property>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionAdd_SEC_node"/>
|
||||
<addaction name="actionExit"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menuHelp">
|
||||
<property name="title">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
<addaction name="actionAbout"/>
|
||||
<addaction name="actionAbout_Qt"/>
|
||||
</widget>
|
||||
<addaction name="menuFile"/>
|
||||
<addaction name="menuHelp"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QToolBar" name="toolBar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionAdd_SEC_node"/>
|
||||
</widget>
|
||||
<action name="actionAdd_SEC_node">
|
||||
<property name="text">
|
||||
<string>Add SEC node</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionExit">
|
||||
<property name="text">
|
||||
<string>Exit</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout">
|
||||
<property name="text">
|
||||
<string>About</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAbout_Qt">
|
||||
<property name="text">
|
||||
<string>About Qt</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>actionExit</sender>
|
||||
<signal>triggered()</signal>
|
||||
<receiver>MainWindow</receiver>
|
||||
<slot>close()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>-1</x>
|
||||
<y>-1</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>613</x>
|
||||
<y>299</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
88
frappy/gui/ui/modulebuttons.ui
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>748</width>
|
||||
<height>74</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="leftMargin">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="verticalSpacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLineEdit" name="currentLineEdit">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>256</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">background-color: lightgrey;</string>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="targetLineEdit"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="targetComboBox"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QPushButton" name="cmdPushButton">
|
||||
<property name="text">
|
||||
<string>Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="3">
|
||||
<widget class="QLineEdit" name="statusLineEdit">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">background-color: lightgrey;</string>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
106
frappy/gui/ui/modulectrl.ui
Normal file
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>257</width>
|
||||
<height>162</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="4" column="0">
|
||||
<widget class="QGroupBox" name="paramGroupBox">
|
||||
<property name="title">
|
||||
<string>Parameters:</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>6</number>
|
||||
</property>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="0">
|
||||
<spacer name="verticalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QGroupBox" name="propertyGroupBox">
|
||||
<property name="title">
|
||||
<string>Properties:</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3"/>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="font">
|
||||
<font>
|
||||
<weight>75</weight>
|
||||
<italic>false</italic>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Module name:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="moduleNameLabel">
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>18</pointsize>
|
||||
<weight>75</weight>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QGroupBox" name="commandGroupBox">
|
||||
<property name="title">
|
||||
<string>Commands:</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
291
frappy/gui/ui/nodectrl.ui
Normal file
@@ -0,0 +1,291 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>652</width>
|
||||
<height>490</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>652</width>
|
||||
<height>490</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="consoleTab">
|
||||
<attribute name="title">
|
||||
<string>Console</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QTextBrowser" name="logTextBrowser">
|
||||
<property name="html">
|
||||
<string><!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:'Noto Sans'; font-size:12pt; 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; font-family:'Sans'; font-size:11pt;"><br /></p></body></html></string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="2">
|
||||
<widget class="QPushButton" name="clearPushButton">
|
||||
<property name="text">
|
||||
<string>Clear</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="msgLineEdit"/>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="text">
|
||||
<string>>>></string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QPushButton" name="sendPushButton">
|
||||
<property name="text">
|
||||
<string>Send</string>
|
||||
</property>
|
||||
<property name="autoDefault">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="default">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="nodeInfoTab">
|
||||
<attribute name="title">
|
||||
<string>NodeInfo</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout_6">
|
||||
<item row="1" column="0">
|
||||
<widget class="QScrollArea" name="scrollArea_2">
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="scrollAreaWidgetContents_2">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>610</width>
|
||||
<height>413</height>
|
||||
</rect>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="font">
|
||||
<font>
|
||||
<weight>75</weight>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Contact point:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="contactPointLabel">
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="font">
|
||||
<font>
|
||||
<weight>75</weight>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Equipment ID:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLabel" name="equipmentIdLabel">
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="font">
|
||||
<font>
|
||||
<weight>75</weight>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Protocol version:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QLabel" name="protocolVersionLabel">
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="font">
|
||||
<font>
|
||||
<weight>75</weight>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Description:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="4" column="0" colspan="2">
|
||||
<widget class="QLabel" name="nodeDescriptionLabel">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>description</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="textInteractionFlags">
|
||||
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0" colspan="2">
|
||||
<widget class="QLabel" name="label55">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
<zorder>label</zorder>
|
||||
<zorder>contactPointLabel</zorder>
|
||||
<zorder>equipmentIdLabel</zorder>
|
||||
<zorder>label_3</zorder>
|
||||
<zorder>protocolVersionLabel</zorder>
|
||||
<zorder>label_5</zorder>
|
||||
<zorder>nodeDescriptionLabel</zorder>
|
||||
<zorder>label_2</zorder>
|
||||
<zorder>verticalSpacer</zorder>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="modulesTab">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<attribute name="title">
|
||||
<string>Modules</string>
|
||||
</attribute>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="0" column="0">
|
||||
<widget class="QScrollArea" name="scrollArea">
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="scrollAreaWidgetContents">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>610</width>
|
||||
<height>413</height>
|
||||
</rect>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetMinimumSize</enum>
|
||||
</property>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>msgLineEdit</sender>
|
||||
<signal>returnPressed()</signal>
|
||||
<receiver>sendPushButton</receiver>
|
||||
<slot>animateClick()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>387</x>
|
||||
<y>459</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>498</x>
|
||||
<y>462</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
83
frappy/gui/ui/parambuttons.ui
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>464</width>
|
||||
<height>33</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>33</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="horizontalSpacing">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="verticalSpacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="margin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Current: </string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="currentLineEdit">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>128</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Set: </string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QLineEdit" name="setLineEdit">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>128</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="4">
|
||||
<widget class="QPushButton" name="setPushButton">
|
||||
<property name="text">
|
||||
<string>Set</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
90
frappy/gui/ui/parambuttons_select.ui
Normal file
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>730</width>
|
||||
<height>39</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="horizontalSpacing">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="verticalSpacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="margin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="0" column="2">
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="4">
|
||||
<widget class="QComboBox" name="setComboBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="5">
|
||||
<spacer name="horizontalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="currentLabel">
|
||||
<property name="text">
|
||||
<string>Current: </string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="3">
|
||||
<widget class="QLabel" name="setLabel">
|
||||
<property name="text">
|
||||
<string>Set: </string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLineEdit" name="currentLineEdit">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="readOnly">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
99
frappy/gui/ui/paramview.ui
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>100</width>
|
||||
<height>100</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="3" column="0">
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="font">
|
||||
<font>
|
||||
<weight>75</weight>
|
||||
<italic>false</italic>
|
||||
<bold>true</bold>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Parameter name:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="paramNameLabel">
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QGroupBox" name="propertyGroupBox">
|
||||
<property name="title">
|
||||
<string>Properties</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>6</number>
|
||||
</property>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeType">
|
||||
<enum>QSizePolicy::Fixed</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
54
frappy/gui/util.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- 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 <alexander.lenz@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
from os import path
|
||||
|
||||
from frappy.gui.qt import uic
|
||||
|
||||
uipath = path.dirname(__file__)
|
||||
|
||||
|
||||
def loadUi(widget, uiname, subdir='ui'):
|
||||
uic.loadUi(path.join(uipath, subdir, uiname), widget)
|
||||
|
||||
|
||||
class Value:
|
||||
def __init__(self, value, timestamp=None, readerror=None):
|
||||
self.value = value
|
||||
self.timestamp = timestamp
|
||||
self.readerror = readerror
|
||||
|
||||
def __str__(self):
|
||||
"""for display"""
|
||||
if self.readerror:
|
||||
return str('!!' + str(self.readerror) + '!!')
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self):
|
||||
args = (self.value,)
|
||||
if self.timestamp:
|
||||
args += (self.timestamp,)
|
||||
if self.readerror:
|
||||
args += (self.readerror,)
|
||||
return 'Value%s' % repr(args)
|
||||
264
frappy/gui/valuewidgets.py
Normal file
@@ -0,0 +1,264 @@
|
||||
# -*- 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
from frappy.datatypes import ArrayOf, BLOBType, BoolType, EnumType, \
|
||||
FloatRange, IntRange, StringType, StructOf, TextType, TupleOf
|
||||
from frappy.gui.qt import QCheckBox, QComboBox, QDialog, \
|
||||
QDoubleSpinBox, QFrame, QGridLayout, QGroupBox, \
|
||||
QLabel, QLineEdit, QSpinBox, QTextEdit, QVBoxLayout
|
||||
from frappy.gui.util import loadUi
|
||||
|
||||
|
||||
# XXX: implement live validators !!!!
|
||||
# XXX: signals upon change of value
|
||||
# XXX: honor readonly in all cases!
|
||||
|
||||
class StringWidget(QLineEdit):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.datatype = datatype
|
||||
if readonly:
|
||||
self.setEnabled(False)
|
||||
|
||||
def get_value(self):
|
||||
res = self.text()
|
||||
return self.datatype(res)
|
||||
|
||||
def set_value(self, value):
|
||||
self.setText(value)
|
||||
|
||||
|
||||
class TextWidget(QTextEdit):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.datatype = datatype
|
||||
if readonly:
|
||||
self.setEnabled(False)
|
||||
|
||||
def get_value(self):
|
||||
res = self.text()
|
||||
return self.datatype(res)
|
||||
|
||||
def set_value(self, value):
|
||||
self.setPlainText(value)
|
||||
|
||||
|
||||
class BlobWidget(StringWidget):
|
||||
# XXX: make an editable hex-table ?
|
||||
pass
|
||||
|
||||
|
||||
# or derive from widget and switch between combobox and radiobuttons?
|
||||
class EnumWidget(QComboBox):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.datatype = datatype
|
||||
|
||||
self._map = {}
|
||||
self._revmap = {}
|
||||
for idx, member in enumerate(datatype._enum.members):
|
||||
self._map[idx] = member
|
||||
self._revmap[member.name] = idx
|
||||
self._revmap[member.value] = idx
|
||||
self.addItem(member.name, member.value)
|
||||
|
||||
def get_value(self):
|
||||
return self._map[self.currentIndex()].value
|
||||
|
||||
def set_value(self, value):
|
||||
self.setCurrentIndex(self._revmap[value])
|
||||
|
||||
|
||||
class BoolWidget(QCheckBox):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.datatype = datatype
|
||||
if readonly:
|
||||
self.setEnabled(False)
|
||||
|
||||
def get_value(self):
|
||||
return self.isChecked()
|
||||
|
||||
def set_value(self, value):
|
||||
self.setChecked(bool(value))
|
||||
|
||||
|
||||
class IntWidget(QSpinBox):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.datatype = datatype
|
||||
if readonly:
|
||||
self.setEnabled(False)
|
||||
self.setMaximum(datatype.max)
|
||||
self.setMinimum(datatype.min)
|
||||
|
||||
def get_value(self):
|
||||
return int(self.value())
|
||||
|
||||
def set_value(self, value):
|
||||
self.setValue(int(value))
|
||||
|
||||
|
||||
class FloatWidget(QDoubleSpinBox):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.datatype = datatype
|
||||
if readonly:
|
||||
self.setEnabled(False)
|
||||
self.setMaximum(datatype.max or 1e6) # XXX!
|
||||
self.setMinimum(datatype.min or 0) # XXX!
|
||||
self.setDecimals(12)
|
||||
|
||||
def get_value(self):
|
||||
return float(self.value())
|
||||
|
||||
def set_value(self, value):
|
||||
self.setValue(float(value))
|
||||
|
||||
|
||||
class TupleWidget(QFrame):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.datatypes = datatype.members
|
||||
|
||||
self.layout = QVBoxLayout()
|
||||
self.subwidgets = []
|
||||
for t in self.datatypes:
|
||||
w = get_widget(t, readonly=readonly, parent=self)
|
||||
w.show()
|
||||
self.layout.addWidget(w)
|
||||
self.subwidgets.append(w)
|
||||
self.setLayout(self.layout)
|
||||
self.show()
|
||||
self.update()
|
||||
|
||||
def get_value(self):
|
||||
return [v(w.get_value()) for w, v in zip(self.subwidgets, self.datatypes)]
|
||||
|
||||
def set_value(self, value):
|
||||
for w, _ in zip(self.subwidgets, value):
|
||||
w.set_value(value)
|
||||
|
||||
|
||||
class StructWidget(QGroupBox):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.layout = QGridLayout()
|
||||
self.subwidgets = {}
|
||||
self.datatypes = []
|
||||
self._labels = []
|
||||
for idx, name in enumerate(sorted(datatype.members)):
|
||||
dt = datatype.members[name]
|
||||
widget = get_widget(dt, readonly=readonly, parent=self)
|
||||
label = QLabel(name)
|
||||
self.layout.addWidget(label, idx, 0)
|
||||
self.layout.addWidget(widget, idx, 1)
|
||||
self._labels.append(label)
|
||||
self.subwidgets[name] = (widget, dt)
|
||||
self.datatypes.append(dt)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def get_value(self):
|
||||
res = {}
|
||||
for name, entry in self.subwidgets.items():
|
||||
w, dt = entry
|
||||
res[name] = dt(w.get_value())
|
||||
return res
|
||||
|
||||
def set_value(self, value):
|
||||
for k, v in value.items():
|
||||
entry = self.subwidgets[k]
|
||||
w, dt = entry
|
||||
w.set_value(dt(v))
|
||||
|
||||
|
||||
class ArrayWidget(QGroupBox):
|
||||
def __init__(self, datatype, readonly=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.datatype = datatype.members
|
||||
|
||||
self.layout = QVBoxLayout()
|
||||
self.subwidgets = []
|
||||
for _ in range(datatype.maxlen):
|
||||
w = get_widget(self.datatype, readonly=readonly, parent=self)
|
||||
self.layout.addWidget(w)
|
||||
self.subwidgets.append(w)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def get_value(self):
|
||||
return [self.datatype(w.get_value()) for w in self.subwidgets]
|
||||
|
||||
def set_value(self, values):
|
||||
for w, v in zip(self.subwidgets, values):
|
||||
w.set_value(v)
|
||||
|
||||
|
||||
def get_widget(datatype, readonly=False, parent=None):
|
||||
return {
|
||||
FloatRange: FloatWidget,
|
||||
IntRange: IntWidget,
|
||||
StringType: StringWidget,
|
||||
TextType: TextWidget,
|
||||
BLOBType: BlobWidget,
|
||||
EnumType: EnumWidget,
|
||||
BoolType: BoolWidget,
|
||||
TupleOf: TupleWidget,
|
||||
StructOf: StructWidget,
|
||||
ArrayOf: ArrayWidget,
|
||||
}.get(datatype.__class__)(datatype, readonly, parent)
|
||||
# TODO: handle NoneOr
|
||||
|
||||
|
||||
class msg(QDialog):
|
||||
def __init__(self, stuff, parent=None):
|
||||
super().__init__(parent)
|
||||
loadUi(self, 'cmddialog.ui')
|
||||
print(dir(self))
|
||||
self.setWindowTitle('Please enter the arguments for calling command "blubb()"')
|
||||
row = 0
|
||||
|
||||
self.gridLayout.addWidget(QLabel('struct'), row, 0)
|
||||
dt = StructOf(i=IntRange(0, 10), f=FloatRange(), b=BoolType())
|
||||
w = StructWidget(dt)
|
||||
self.gridLayout.addWidget(w, row, 1)
|
||||
row += 1
|
||||
|
||||
self.gridLayout.addWidget(QLabel('stuff'), row, 0, 1, 0)
|
||||
row += 1 # at pos (0,0) span 2 cols, 1 row
|
||||
self.gridLayout.setRowStretch(row, 1)
|
||||
self.setModal(True)
|
||||
|
||||
def accept(self):
|
||||
print('accepted')
|
||||
super().accept()
|
||||
|
||||
def reject(self):
|
||||
print('rejected')
|
||||
super().reject()
|
||||
|
||||
def done(self, how):
|
||||
print('done(%r)' % how)
|
||||
return super().done(how)
|
||||
421
frappy/io.py
Normal file
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
# *****************************************************************************
|
||||
"""stream oriented input / output
|
||||
|
||||
May be used for TCP/IP as well for serial IO or
|
||||
other future extensions of AsynConn
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
|
||||
from frappy.lib.asynconn import AsynConn, ConnectionClosed
|
||||
from frappy.datatypes import ArrayOf, BLOBType, BoolType, FloatRange, IntRange, \
|
||||
StringType, TupleOf, ValueType
|
||||
from frappy.errors import CommunicationFailedError, ConfigError, ProgrammingError
|
||||
from frappy.modules import Attached, Command, \
|
||||
Communicator, Done, Module, Parameter, Property
|
||||
from frappy.lib import generalConfig
|
||||
|
||||
generalConfig.set_default('legacy_hasiodev', False)
|
||||
|
||||
HEX_CODE = re.compile(r'[0-9a-fA-F][0-9a-fA-F]$')
|
||||
|
||||
|
||||
class SilentError(CommunicationFailedError):
|
||||
silent = True
|
||||
|
||||
|
||||
class HasIO(Module):
|
||||
"""Mixin for modules using a communicator"""
|
||||
io = Attached(mandatory=False) # either io or uri must be given
|
||||
uri = Property('uri for automatic creation of the attached communication module',
|
||||
StringType(), default='')
|
||||
|
||||
ioDict = {}
|
||||
ioClass = None
|
||||
|
||||
def __init__(self, name, logger, opts, srv):
|
||||
io = opts.get('io')
|
||||
super().__init__(name, logger, opts, srv)
|
||||
if self.uri:
|
||||
opts = {'uri': self.uri, 'description': 'communication device for %s' % name,
|
||||
'export': False}
|
||||
ioname = self.ioDict.get(self.uri)
|
||||
if not ioname:
|
||||
ioname = io or name + '_io'
|
||||
io = self.ioClass(ioname, srv.log.getChild(ioname), opts, srv) # pylint: disable=not-callable
|
||||
io.callingModule = []
|
||||
srv.modules[ioname] = io
|
||||
self.ioDict[self.uri] = ioname
|
||||
self.io = ioname
|
||||
elif not io:
|
||||
raise ConfigError("Module %s needs a value for either 'uri' or 'io'" % name)
|
||||
|
||||
def communicate(self, *args):
|
||||
return self.io.communicate(*args)
|
||||
|
||||
def multicomm(self, *args):
|
||||
return self.io.multicomm(*args)
|
||||
|
||||
|
||||
class HasIodev(HasIO):
|
||||
# TODO: remove this legacy mixin
|
||||
iodevClass = None
|
||||
|
||||
@property
|
||||
def _iodev(self):
|
||||
return self.io
|
||||
|
||||
def __init__(self, name, logger, opts, srv):
|
||||
self.ioClass = self.iodevClass
|
||||
super().__init__(name, logger, opts, srv)
|
||||
if generalConfig.legacy_hasiodev:
|
||||
self.log.warn('using the HasIodev mixin is deprecated - use HasIO instead')
|
||||
else:
|
||||
self.log.error('legacy HasIodev no longer supported')
|
||||
self.log.error('you may suppress this error message by running the server with --relaxed')
|
||||
raise ProgrammingError('legacy HasIodev no longer supported')
|
||||
self.sendRecv = self.communicate
|
||||
|
||||
|
||||
class IOBase(Communicator):
|
||||
"""base of StringIO and BytesIO"""
|
||||
uri = Property('hostname:portnumber', datatype=StringType())
|
||||
timeout = Parameter('timeout', datatype=FloatRange(0), default=2)
|
||||
wait_before = Parameter('wait time before sending', datatype=FloatRange(), default=0)
|
||||
is_connected = Parameter('connection state', datatype=BoolType(), readonly=False, default=False)
|
||||
pollinterval = Parameter('reconnect interval', datatype=FloatRange(0), readonly=False, default=10)
|
||||
|
||||
_reconnectCallbacks = None
|
||||
_conn = None
|
||||
_last_error = None
|
||||
_lock = None
|
||||
_last_connect_attempt = 0
|
||||
|
||||
def earlyInit(self):
|
||||
super().earlyInit()
|
||||
self._reconnectCallbacks = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def connectStart(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def closeConnection(self):
|
||||
"""close connection
|
||||
|
||||
self.is_connected MUST be set to False by implementors
|
||||
"""
|
||||
self._conn.disconnect()
|
||||
self._conn = None
|
||||
self.is_connected = False
|
||||
|
||||
def doPoll(self):
|
||||
self.read_is_connected()
|
||||
|
||||
def read_is_connected(self):
|
||||
"""try to reconnect, when not connected
|
||||
|
||||
self.is_connected is changed only by self.connectStart or self.closeConnection
|
||||
"""
|
||||
if self.is_connected:
|
||||
return Done # no need for intermediate updates
|
||||
try:
|
||||
self.connectStart()
|
||||
if self._last_error:
|
||||
self.log.info('connected')
|
||||
self._last_error = 'connected'
|
||||
self.callCallbacks()
|
||||
return Done
|
||||
except Exception as e:
|
||||
if str(e) != self._last_error:
|
||||
self._last_error = str(e)
|
||||
self.log.error(self._last_error)
|
||||
raise SilentError(repr(e)) from e
|
||||
return Done
|
||||
|
||||
def write_is_connected(self, value):
|
||||
"""value = True: connect if not yet done
|
||||
value = False: disconnect (will be reconnected automatically)
|
||||
"""
|
||||
if not value:
|
||||
self.closeConnection()
|
||||
return False
|
||||
return self.read_is_connected()
|
||||
|
||||
def check_connection(self):
|
||||
"""called before communicate"""
|
||||
if not self.is_connected:
|
||||
now = time.time()
|
||||
if now >= self._last_connect_attempt + self.pollinterval:
|
||||
# we do not try to reconnect more often than pollinterval
|
||||
_last_connect_attempt = now
|
||||
if self.read_is_connected():
|
||||
return
|
||||
raise SilentError('disconnected') from None
|
||||
|
||||
def registerReconnectCallback(self, name, func):
|
||||
"""register reconnect callback
|
||||
|
||||
if the callback fails or returns False, it is cleared
|
||||
"""
|
||||
self._reconnectCallbacks[name] = func
|
||||
|
||||
def callCallbacks(self):
|
||||
for key, cb in list(self._reconnectCallbacks.items()):
|
||||
try:
|
||||
removeme = not cb()
|
||||
except Exception as e:
|
||||
self.log.error('callback: %s' % e)
|
||||
removeme = True
|
||||
if removeme:
|
||||
self._reconnectCallbacks.pop(key)
|
||||
|
||||
def communicate(self, command):
|
||||
return NotImplementedError
|
||||
|
||||
|
||||
class StringIO(IOBase):
|
||||
"""line oriented communicator
|
||||
|
||||
self healing is assured by polling the parameter 'is_connected'
|
||||
"""
|
||||
end_of_line = Property('end_of_line character', datatype=ValueType(),
|
||||
default='\n', settable=True)
|
||||
encoding = Property('used encoding', datatype=StringType(),
|
||||
default='ascii', settable=True)
|
||||
identification = Property('''
|
||||
identification
|
||||
|
||||
a list of tuples with commands and expected responses as regexp,
|
||||
to be sent on connect''',
|
||||
datatype=ArrayOf(TupleOf(StringType(), StringType())), default=[], export=False)
|
||||
|
||||
def _convert_eol(self, value):
|
||||
if isinstance(value, str):
|
||||
return value.encode(self.encoding)
|
||||
if isinstance(value, int):
|
||||
return bytes([value])
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
raise ValueError('invalid end_of_line: %s' % repr(value))
|
||||
|
||||
def earlyInit(self):
|
||||
super().earlyInit()
|
||||
eol = self.end_of_line
|
||||
if isinstance(eol, (tuple, list)):
|
||||
if len(eol) not in (1, 2):
|
||||
raise ValueError('invalid end_of_line: %s' % eol)
|
||||
else:
|
||||
eol = [eol]
|
||||
# eol for read and write might be distinct
|
||||
self._eol_read = self._convert_eol(eol[0])
|
||||
if not self._eol_read:
|
||||
raise ValueError('end_of_line for read must not be empty')
|
||||
self._eol_write = self._convert_eol(eol[-1])
|
||||
|
||||
def connectStart(self):
|
||||
if not self.is_connected:
|
||||
uri = self.uri
|
||||
self._conn = AsynConn(uri, self._eol_read)
|
||||
self.is_connected = True
|
||||
for command, regexp in self.identification:
|
||||
reply = self.communicate(command)
|
||||
if not re.match(regexp, reply):
|
||||
self.closeConnection()
|
||||
raise CommunicationFailedError('bad response: %s does not match %s' %
|
||||
(reply, regexp))
|
||||
|
||||
@Command(StringType(), result=StringType())
|
||||
def communicate(self, command):
|
||||
"""send a command and receive a reply
|
||||
|
||||
using end_of_line, encoding and self._lock
|
||||
for commands without reply, the command must be joined with a query command,
|
||||
wait_before is respected for end_of_lines within a command.
|
||||
"""
|
||||
command = command.encode(self.encoding)
|
||||
self.check_connection()
|
||||
try:
|
||||
with self._lock:
|
||||
# read garbage and wait before send
|
||||
if self.wait_before and self._eol_write:
|
||||
cmds = command.split(self._eol_write)
|
||||
else:
|
||||
cmds = [command]
|
||||
garbage = None
|
||||
try:
|
||||
for cmd in cmds:
|
||||
if self.wait_before:
|
||||
time.sleep(self.wait_before)
|
||||
if garbage is None: # read garbage only once
|
||||
garbage = self._conn.flush_recv()
|
||||
if garbage:
|
||||
self.comLog('garbage: %r', garbage)
|
||||
self._conn.send(cmd + self._eol_write)
|
||||
self.comLog('> %s', cmd.decode(self.encoding))
|
||||
reply = self._conn.readline(self.timeout)
|
||||
except ConnectionClosed:
|
||||
self.closeConnection()
|
||||
raise CommunicationFailedError('disconnected') from None
|
||||
reply = reply.decode(self.encoding)
|
||||
self.comLog('< %s', reply)
|
||||
return reply
|
||||
except Exception as e:
|
||||
if self._conn is None:
|
||||
raise SilentError('disconnected') from None
|
||||
if repr(e) != self._last_error:
|
||||
self._last_error = str(e)
|
||||
self.log.error(self._last_error)
|
||||
raise SilentError(repr(e)) from e
|
||||
|
||||
@Command(ArrayOf(StringType()), result=ArrayOf(StringType()))
|
||||
def multicomm(self, commands):
|
||||
"""communicate multiple request/replies in one row"""
|
||||
replies = []
|
||||
with self._lock:
|
||||
for cmd in commands:
|
||||
replies.append(self.communicate(cmd))
|
||||
return replies
|
||||
|
||||
|
||||
def make_regexp(string):
|
||||
"""create a bytes regexp pattern from a string describing a bytes pattern
|
||||
|
||||
:param string: a string containing white space separated items containing either
|
||||
- a two digit hexadecimal number (byte value)
|
||||
- a character from first unicode page, to be replaced by its code
|
||||
- ?? indicating any byte
|
||||
|
||||
:return: a tuple of length and compiled re pattern
|
||||
Example: make_regexp('00 ff A ??') == (4, re.compile(b'\x00\xffA.'))
|
||||
"""
|
||||
relist = [b'.' if c == '??' else
|
||||
re.escape(bytes([int(c, 16) if HEX_CODE.match(c) else ord(c)]))
|
||||
for c in string.split()]
|
||||
return len(relist), re.compile(b''.join(relist) + b'$')
|
||||
|
||||
|
||||
def make_bytes(string):
|
||||
"""create bytes from a string describing bytes
|
||||
|
||||
:param string: a string containing white space separated items containing either
|
||||
- a two digit hexadecimal number (byte value)
|
||||
- a character from first unicode page, to be replaced by its code
|
||||
|
||||
:return: the bytes
|
||||
Example: make_bytes('02 A 20 B 03') == b'\x02A B\x03'
|
||||
"""
|
||||
return bytes([int(c, 16) if HEX_CODE.match(c) else ord(c) for c in string.split()])
|
||||
|
||||
|
||||
def hexify(bytes_):
|
||||
return ' '.join('%02x' % r for r in bytes_)
|
||||
|
||||
|
||||
class BytesIO(IOBase):
|
||||
identification = Property(
|
||||
"""identification
|
||||
|
||||
a list of tuples with requests and expected responses, to be sent on connect.
|
||||
requests and responses are whitespace separated items
|
||||
an item is either:
|
||||
- a two digit hexadecimal number (byte value)
|
||||
- a character
|
||||
- ?? indicating ignored bytes in responses
|
||||
""", datatype=ArrayOf(TupleOf(StringType(), StringType())), default=[], export=False)
|
||||
|
||||
def connectStart(self):
|
||||
if not self.is_connected:
|
||||
uri = self.uri
|
||||
self._conn = AsynConn(uri, b'')
|
||||
self.is_connected = True
|
||||
for request, expected in self.identification:
|
||||
replylen, replypat = make_regexp(expected)
|
||||
reply = self.communicate(make_bytes(request), replylen)
|
||||
if not replypat.match(reply):
|
||||
self.closeConnection()
|
||||
raise CommunicationFailedError('bad response: %r does not match %r' % (reply, expected))
|
||||
|
||||
@Command((BLOBType(), IntRange(0)), result=BLOBType())
|
||||
def communicate(self, request, replylen): # pylint: disable=arguments-differ
|
||||
"""send a request and receive (at least) <replylen> bytes as reply"""
|
||||
self.check_connection()
|
||||
try:
|
||||
with self._lock:
|
||||
# read garbage and wait before send
|
||||
try:
|
||||
if self.wait_before:
|
||||
time.sleep(self.wait_before)
|
||||
garbage = self._conn.flush_recv()
|
||||
if garbage:
|
||||
self.comLog('garbage: %r', garbage)
|
||||
self._conn.send(request)
|
||||
self.comLog('> %s', hexify(request))
|
||||
reply = self._conn.readbytes(replylen, self.timeout)
|
||||
except ConnectionClosed:
|
||||
self.closeConnection()
|
||||
raise CommunicationFailedError('disconnected') from None
|
||||
self.comLog('< %s', hexify(reply))
|
||||
return self.getFullReply(request, reply)
|
||||
except Exception as e:
|
||||
if self._conn is None:
|
||||
raise SilentError('disconnected') from None
|
||||
if repr(e) != self._last_error:
|
||||
self._last_error = str(e)
|
||||
self.log.error(self._last_error)
|
||||
raise SilentError(repr(e)) from e
|
||||
|
||||
@Command((ArrayOf(TupleOf(BLOBType(), IntRange(0)))), result=ArrayOf(BLOBType()))
|
||||
def multicomm(self, requests):
|
||||
"""communicate multiple request/replies in one row"""
|
||||
replies = []
|
||||
with self._lock:
|
||||
for request in requests:
|
||||
replies.append(self.communicate(*request))
|
||||
return replies
|
||||
|
||||
def readBytes(self, nbytes):
|
||||
"""read bytes
|
||||
|
||||
:param nbytes: the number of expected bytes
|
||||
:return: the returned bytes
|
||||
"""
|
||||
return self._conn.readbytes(nbytes, self.timeout)
|
||||
|
||||
def getFullReply(self, request, replyheader):
|
||||
"""to be overwritten in case the reply length is variable
|
||||
|
||||
:param request: the request
|
||||
:param replyheader: the already received bytes
|
||||
:return: the full reply (replyheader + additional bytes)
|
||||
|
||||
When the reply length is variable, :meth:`communicate` should be called
|
||||
with the `replylen` argument set to the minimum expected length of the reply.
|
||||
Typically this method determines then the length of additional bytes from
|
||||
the already received bytes (replyheader) and/or the request and calls
|
||||
:meth:`readBytes` to get the remaining bytes.
|
||||
|
||||
Remark: this mechanism avoids the need to call readBytes after communicate
|
||||
separately, which would not honour the lock properly.
|
||||
"""
|
||||
return replyheader
|
||||
372
frappy/lib/__init__.py
Normal file
@@ -0,0 +1,372 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define helpers"""
|
||||
|
||||
import importlib
|
||||
import linecache
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from configparser import ConfigParser
|
||||
from os import environ, path
|
||||
|
||||
|
||||
class GeneralConfig:
|
||||
"""generalConfig holds server configuration items
|
||||
|
||||
generalConfig.init is to be called before starting the server.
|
||||
Accessing generalConfig.<key> raises an error, when generalConfig.init is
|
||||
not yet called, except when a default for <key> is set.
|
||||
For tests and for imports from client code, a module may access generalConfig
|
||||
without calling generalConfig.init before. For this, it should call
|
||||
generalConfig.set_default on import to define defaults for the needed keys.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._config = None
|
||||
self.defaults = {} #: default values. may be set before or after :meth:`init`
|
||||
|
||||
def init(self, configfile=None):
|
||||
"""init default server configuration
|
||||
|
||||
:param configfile: if present, keys and values from the [FRAPPY] section are read
|
||||
|
||||
default values for 'piddir', 'logdir' and 'confdir' are guessed from the
|
||||
location of this source file and from sys.executable.
|
||||
|
||||
if configfile is not given, the general config file is determined by
|
||||
the env. variable FRAPPY_CONFIG_FILE or <confdir>/generalConfig.cfg is used
|
||||
|
||||
if a configfile is given, the values from the FRAPPY section are
|
||||
overriding above defaults
|
||||
|
||||
finally, the env. variables FRAPPY_PIDDIR, FRAPPY_LOGDIR and FRAPPY_CONFDIR
|
||||
are overriding these values when given
|
||||
"""
|
||||
cfg = {}
|
||||
mandatory = 'piddir', 'logdir', 'confdir'
|
||||
repodir = path.abspath(path.join(path.dirname(__file__), '..', '..'))
|
||||
# create default paths
|
||||
if path.splitext(sys.executable)[1] == ".exe" and not path.basename(sys.executable).startswith('python'):
|
||||
# special MS windows environment
|
||||
cfg.update(piddir='./', logdir='./log', confdir='./')
|
||||
elif path.exists(path.join(repodir, '.git')):
|
||||
# running from git repo
|
||||
cfg['confdir'] = path.join(repodir, 'cfg')
|
||||
# take logdir and piddir from <repodir>/cfg/generalConfig.cfg
|
||||
else:
|
||||
# running on installed system (typically with systemd)
|
||||
cfg.update(piddir='/var/run/frappy', logdir='/var/log', confdir='/etc/frappy')
|
||||
if configfile is None:
|
||||
configfile = environ.get('FRAPPY_CONFIG_FILE')
|
||||
if configfile:
|
||||
configfile = path.expanduser(configfile)
|
||||
if not path.exists(configfile):
|
||||
raise FileNotFoundError(configfile)
|
||||
else:
|
||||
configfile = path.join(cfg['confdir'], 'generalConfig.cfg')
|
||||
if not path.exists(configfile):
|
||||
configfile = None
|
||||
if configfile:
|
||||
parser = ConfigParser()
|
||||
parser.optionxform = str
|
||||
parser.read([configfile])
|
||||
# only the FRAPPY section is relevant, other sections might be used by others
|
||||
for key, value in parser['FRAPPY'].items():
|
||||
if value.startswith('./'):
|
||||
cfg[key] = path.abspath(path.join(repodir, value))
|
||||
else:
|
||||
# expand ~ to username, also in path lists separated with ':'
|
||||
cfg[key] = ':'.join(path.expanduser(v) for v in value.split(':'))
|
||||
if cfg.get('confdir') is None:
|
||||
cfg['confdir'] = path.dirname(configfile)
|
||||
for key in mandatory:
|
||||
cfg[key] = environ.get('FRAPPY_%s' % key.upper(), cfg.get(key))
|
||||
missing_keys = [key for key in mandatory if cfg[key] is None]
|
||||
if missing_keys:
|
||||
if configfile:
|
||||
raise KeyError('missing value for %s in %s' % (' and '.join(missing_keys), configfile))
|
||||
raise KeyError('missing %s' % ' and '.join('FRAPPY_%s' % k.upper() for k in missing_keys))
|
||||
# this is not customizable
|
||||
cfg['basedir'] = repodir
|
||||
self._config = cfg
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""access for keys known to exist
|
||||
|
||||
:param key: the key (raises an error when key is not available)
|
||||
:return: the value
|
||||
"""
|
||||
try:
|
||||
return self._config[key]
|
||||
except KeyError:
|
||||
return self.defaults[key]
|
||||
except TypeError:
|
||||
if key in self.defaults:
|
||||
# accept retrieving defaults before init
|
||||
# e.g. 'lazy_number_validation' in frappy.datatypes
|
||||
return self.defaults[key]
|
||||
raise TypeError('generalConfig.init() has to be called first') from None
|
||||
|
||||
def get(self, key, default=None):
|
||||
"""access for keys not known to exist"""
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def getint(self, key, default=None):
|
||||
"""access and convert to int"""
|
||||
try:
|
||||
return int(self[key])
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __getattr__(self, key):
|
||||
"""goodie: use generalConfig.<key> instead of generalConfig.get('<key>')"""
|
||||
return self.get(key)
|
||||
|
||||
@property
|
||||
def initialized(self):
|
||||
return bool(self._config)
|
||||
|
||||
def set_default(self, key, value):
|
||||
"""set a default value, in case not set already"""
|
||||
if key not in self.defaults:
|
||||
self.defaults[key] = value
|
||||
|
||||
def testinit(self, **kwds):
|
||||
"""for test purposes"""
|
||||
self._config = kwds
|
||||
|
||||
|
||||
generalConfig = GeneralConfig()
|
||||
|
||||
|
||||
class lazy_property:
|
||||
"""A property that calculates its value only once."""
|
||||
|
||||
def __init__(self, func):
|
||||
self._func = func
|
||||
self.__name__ = func.__name__
|
||||
self.__doc__ = func.__doc__
|
||||
|
||||
def __get__(self, obj, obj_class):
|
||||
if obj is None:
|
||||
return self
|
||||
obj.__dict__[self.__name__] = self._func(obj)
|
||||
return obj.__dict__[self.__name__]
|
||||
|
||||
|
||||
class attrdict(dict):
|
||||
"""a normal dict, providing access also via attributes"""
|
||||
|
||||
def __getattr__(self, key):
|
||||
return self[key]
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
self[key] = value
|
||||
|
||||
|
||||
def clamp(_min, value, _max):
|
||||
"""return the median of 3 values,
|
||||
|
||||
i.e. value if min <= value <= max, else min or max depending on which side
|
||||
value lies outside the [min..max] interval. This works even when min > max!
|
||||
"""
|
||||
# return median, i.e. clamp the the value between min and max
|
||||
return sorted([_min, value, _max])[1]
|
||||
|
||||
|
||||
def get_class(spec):
|
||||
"""loads a class given by string in dotted notation (as python would do)"""
|
||||
modname, classname = spec.rsplit('.', 1)
|
||||
if modname.startswith('frappy'):
|
||||
module = importlib.import_module(modname)
|
||||
else:
|
||||
# rarely needed by now....
|
||||
module = importlib.import_module('frappy.' + modname)
|
||||
try:
|
||||
return getattr(module, classname)
|
||||
except AttributeError:
|
||||
raise AttributeError('no such class') from None
|
||||
|
||||
|
||||
def mkthread(func, *args, **kwds):
|
||||
t = threading.Thread(
|
||||
name='%s:%s' % (func.__module__, func.__name__),
|
||||
target=func,
|
||||
args=args,
|
||||
kwargs=kwds)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def formatExtendedFrame(frame):
|
||||
ret = []
|
||||
for key, value in frame.f_locals.items():
|
||||
try:
|
||||
valstr = repr(value)[:256]
|
||||
except Exception:
|
||||
valstr = '<cannot be displayed>'
|
||||
ret.append(' %-20s = %s\n' % (key, valstr))
|
||||
ret.append('\n')
|
||||
return ret
|
||||
|
||||
|
||||
def formatExtendedTraceback(exc_info=None):
|
||||
if exc_info is None:
|
||||
etype, value, tb = sys.exc_info()
|
||||
else:
|
||||
etype, value, tb = exc_info
|
||||
ret = ['Traceback (most recent call last):\n']
|
||||
while tb is not None:
|
||||
frame = tb.tb_frame
|
||||
filename = frame.f_code.co_filename
|
||||
item = ' File "%s", line %d, in %s\n' % (filename, tb.tb_lineno,
|
||||
frame.f_code.co_name)
|
||||
linecache.checkcache(filename)
|
||||
line = linecache.getline(filename, tb.tb_lineno, frame.f_globals)
|
||||
if line:
|
||||
item = item + ' %s\n' % line.strip()
|
||||
ret.append(item)
|
||||
if filename not in ('<script>', '<string>'):
|
||||
ret += formatExtendedFrame(tb.tb_frame)
|
||||
tb = tb.tb_next
|
||||
ret += traceback.format_exception_only(etype, value)
|
||||
return ''.join(ret).rstrip('\n')
|
||||
|
||||
|
||||
def formatExtendedStack(level=1):
|
||||
f = sys._getframe(level)
|
||||
ret = ['Stack trace (most recent call last):\n\n']
|
||||
while f is not None:
|
||||
lineno = f.f_lineno
|
||||
co = f.f_code
|
||||
filename = co.co_filename
|
||||
name = co.co_name
|
||||
item = ' File "%s", line %d, in %s\n' % (filename, lineno, name)
|
||||
linecache.checkcache(filename)
|
||||
line = linecache.getline(filename, lineno, f.f_globals)
|
||||
if line:
|
||||
item = item + ' %s\n' % line.strip()
|
||||
ret.insert(1, item)
|
||||
if filename != '<script>':
|
||||
ret[2:2] = formatExtendedFrame(f)
|
||||
f = f.f_back
|
||||
return ''.join(ret).rstrip('\n')
|
||||
|
||||
|
||||
def formatException(cut=0, exc_info=None, verbose=False):
|
||||
"""Format an exception with traceback, but leave out the first `cut`
|
||||
number of frames.
|
||||
"""
|
||||
if verbose:
|
||||
return formatExtendedTraceback(exc_info)
|
||||
if exc_info is None:
|
||||
typ, val, tb = sys.exc_info()
|
||||
else:
|
||||
typ, val, tb = exc_info
|
||||
res = ['Traceback (most recent call last):\n']
|
||||
tbres = traceback.format_tb(tb, sys.maxsize)
|
||||
res += tbres[cut:]
|
||||
res += traceback.format_exception_only(typ, val)
|
||||
return ''.join(res)
|
||||
|
||||
|
||||
def parseHostPort(host, defaultport):
|
||||
"""Parse host[:port] string and tuples
|
||||
|
||||
Specify 'host[:port]' or a (host, port) tuple for the mandatory argument.
|
||||
If the port specification is missing, the value of the defaultport is used.
|
||||
"""
|
||||
|
||||
if isinstance(host, (tuple, list)):
|
||||
host, port = host
|
||||
elif ':' in host:
|
||||
host, port = host.rsplit(':', 1)
|
||||
port = int(port)
|
||||
else:
|
||||
port = defaultport
|
||||
assert 0 < port < 65536
|
||||
assert ':' not in host
|
||||
return host, port
|
||||
|
||||
|
||||
def tcpSocket(host, defaultport, timeout=None):
|
||||
"""Helper for opening a TCP client socket to a remote server.
|
||||
|
||||
Specify 'host[:port]' or a (host, port) tuple for the mandatory argument.
|
||||
If the port specification is missing, the value of the defaultport is used.
|
||||
If timeout is set to a number, the timout of the connection is set to this
|
||||
number, else the socket stays in blocking mode.
|
||||
"""
|
||||
host, port = parseHostPort(host, defaultport)
|
||||
|
||||
# open socket and set options
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
if timeout:
|
||||
s.settimeout(timeout)
|
||||
# connect
|
||||
s.connect((host, int(port)))
|
||||
return s
|
||||
|
||||
|
||||
# keep a reference to socket to avoid (interpreter) shut-down problems
|
||||
def closeSocket(sock, socket=socket): # pylint: disable=redefined-outer-name
|
||||
"""Do our best to close a socket."""
|
||||
if sock is None:
|
||||
return
|
||||
try:
|
||||
sock.shutdown(socket.SHUT_RDWR)
|
||||
except socket.error:
|
||||
pass
|
||||
try:
|
||||
sock.close()
|
||||
except socket.error:
|
||||
pass
|
||||
|
||||
|
||||
def getfqdn(name=''):
|
||||
"""Get fully qualified hostname."""
|
||||
return socket.getfqdn(name)
|
||||
|
||||
|
||||
def formatStatusBits(sword, labels, start=0):
|
||||
"""Return a list of labels according to bit state in `sword` starting
|
||||
with bit `start` and the first label in `labels`.
|
||||
"""
|
||||
result = []
|
||||
for i, lbl in enumerate(labels, start):
|
||||
if sword & (1 << i) and lbl:
|
||||
result.append(lbl)
|
||||
return result
|
||||
|
||||
|
||||
class UniqueObject:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
287
frappy/lib/asynconn.py
Normal file
@@ -0,0 +1,287 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
"""asynchronous connections
|
||||
|
||||
generic class for byte oriented communication
|
||||
includes implementation for TCP and Serial connections
|
||||
support for asynchronous communication, but may be used also for
|
||||
synchronous IO (see frappy.io)
|
||||
"""
|
||||
|
||||
import ast
|
||||
import select
|
||||
import socket
|
||||
import time
|
||||
|
||||
from frappy.errors import CommunicationFailedError, ConfigError
|
||||
from frappy.lib import closeSocket, parseHostPort, tcpSocket
|
||||
|
||||
try:
|
||||
from serial import Serial
|
||||
except ImportError:
|
||||
Serial = None
|
||||
|
||||
|
||||
class ConnectionClosed(ConnectionError):
|
||||
pass
|
||||
|
||||
|
||||
class AsynConn:
|
||||
timeout = 1 # inter byte timeout
|
||||
scheme = None
|
||||
SCHEME_MAP = {}
|
||||
connection = None # is not None, if connected
|
||||
defaultport = None
|
||||
|
||||
def __new__(cls, uri, end_of_line=b'\n'):
|
||||
scheme = uri.split('://')[0]
|
||||
iocls = cls.SCHEME_MAP.get(scheme, None)
|
||||
if not iocls:
|
||||
# try tcp, if scheme not given
|
||||
try:
|
||||
host_port = parseHostPort(uri, cls.defaultport)
|
||||
except (ValueError, TypeError, AssertionError):
|
||||
if 'COM' in uri:
|
||||
raise ValueError("the correct uri for a COM port is: "
|
||||
"'serial://COM<i>[?<option>=<value>[+<option>=value ...]]'") from None
|
||||
if '/dev' in uri:
|
||||
raise ValueError("the correct uri for a serial port is: "
|
||||
"'serial:///dev/<tty>[?<option>=<value>[+<option>=value ...]]'") from None
|
||||
raise ValueError('invalid uri: %s' % uri) from None
|
||||
iocls = cls.SCHEME_MAP['tcp']
|
||||
uri = 'tcp://%s:%d' % host_port
|
||||
return object.__new__(iocls)
|
||||
|
||||
def __init__(self, uri, end_of_line=b'\n'):
|
||||
self.end_of_line = end_of_line
|
||||
self._rxbuffer = b''
|
||||
|
||||
def __del__(self):
|
||||
self.disconnect()
|
||||
|
||||
@classmethod
|
||||
def __init_subclass__(cls):
|
||||
"""register subclass to scheme, if available"""
|
||||
if cls.scheme:
|
||||
cls.SCHEME_MAP[cls.scheme] = cls
|
||||
|
||||
def disconnect(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def send(self, data):
|
||||
"""send data (bytes!)
|
||||
|
||||
tries to send all data"""
|
||||
raise NotImplementedError
|
||||
|
||||
def recv(self):
|
||||
"""return bytes received within timeout
|
||||
|
||||
in contrast to socket.recv:
|
||||
- returns b'' on timeout
|
||||
- raises ConnectionClosed if the other end has disconnected
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def flush_recv(self):
|
||||
"""flush all available bytes (return them)"""
|
||||
raise NotImplementedError
|
||||
|
||||
def readline(self, timeout=None):
|
||||
"""read one line
|
||||
|
||||
return either a complete line or None if no data available within 1 sec (self.timeout)
|
||||
if a non-zero timeout is given, a timeout error is raised instead of returning None
|
||||
the timeout effectively used will not be lower than self.timeout (1 sec)
|
||||
"""
|
||||
if timeout:
|
||||
end = time.time() + timeout
|
||||
while True:
|
||||
splitted = self._rxbuffer.split(self.end_of_line, 1)
|
||||
if len(splitted) == 2:
|
||||
line, self._rxbuffer = splitted
|
||||
return line
|
||||
data = self.recv()
|
||||
if not data:
|
||||
if timeout:
|
||||
if time.time() < end:
|
||||
continue
|
||||
raise TimeoutError('timeout in readline (%g sec)' % timeout)
|
||||
return None
|
||||
self._rxbuffer += data
|
||||
|
||||
def readbytes(self, nbytes, timeout=None):
|
||||
"""read a fixed number of bytes
|
||||
|
||||
return either <nbytes> bytes or None if not enough data available within 1 sec (self.timeout)
|
||||
if a non-zero timeout is given, a timeout error is raised instead of returning None
|
||||
the timeout effectively used will not be lower than self.timeout (1 sec)
|
||||
"""
|
||||
if timeout:
|
||||
end = time.time() + timeout
|
||||
while len(self._rxbuffer) < nbytes:
|
||||
data = self.recv()
|
||||
if not data:
|
||||
if timeout:
|
||||
if time.time() < end:
|
||||
continue
|
||||
raise TimeoutError('timeout in readbytes (%g sec)' % timeout)
|
||||
return None
|
||||
self._rxbuffer += data
|
||||
line = self._rxbuffer[:nbytes]
|
||||
self._rxbuffer = self._rxbuffer[nbytes:]
|
||||
return line
|
||||
|
||||
def writeline(self, line):
|
||||
self.send(line + self.end_of_line)
|
||||
|
||||
|
||||
class AsynTcp(AsynConn):
|
||||
scheme = 'tcp'
|
||||
|
||||
def __init__(self, uri, *args, **kwargs):
|
||||
super().__init__(uri, *args, **kwargs)
|
||||
self.uri = uri
|
||||
if uri.startswith('tcp://'):
|
||||
# should be the case always
|
||||
uri = uri[6:]
|
||||
try:
|
||||
self.connection = tcpSocket(uri, self.defaultport, self.timeout)
|
||||
except (ConnectionRefusedError, socket.gaierror, socket.timeout) as e:
|
||||
# indicate that retrying might make sense
|
||||
raise CommunicationFailedError(str(e)) from None
|
||||
|
||||
def disconnect(self):
|
||||
if self.connection:
|
||||
closeSocket(self.connection)
|
||||
self.connection = None
|
||||
|
||||
def send(self, data):
|
||||
"""send data (bytes!)"""
|
||||
# remark: will raise socket.timeout when output buffer is full and blocked for 1 sec
|
||||
self.connection.sendall(data)
|
||||
|
||||
def flush_recv(self):
|
||||
"""flush recv buffer"""
|
||||
data = [self._rxbuffer]
|
||||
while select.select([self.connection], [], [], 0)[0]:
|
||||
data.append(self.recv())
|
||||
self._rxbuffer = b''
|
||||
return b''.join(data)
|
||||
|
||||
def recv(self):
|
||||
"""return bytes in the recv buffer
|
||||
|
||||
or bytes received within 1 sec
|
||||
"""
|
||||
try:
|
||||
data = self.connection.recv(8192)
|
||||
if data:
|
||||
return data
|
||||
except (socket.timeout, TimeoutError):
|
||||
# timeout while waiting
|
||||
return b''
|
||||
# note that when no data is sent on a connection, an interruption might
|
||||
# not be detected within a reasonable time. sending a heartbeat should
|
||||
# help in this case.
|
||||
raise ConnectionClosed() # marks end of connection
|
||||
|
||||
|
||||
class AsynSerial(AsynConn):
|
||||
"""a serial connection using pyserial
|
||||
|
||||
uri syntax:
|
||||
serial://<path>?[<option>=<value>[+<option>=<value> ...]]
|
||||
|
||||
options (defaults, other examples):
|
||||
|
||||
baudrate=9600 # 4800, 115200
|
||||
bytesize=8 # 5,6,7
|
||||
parity=none # even, odd, mark, space
|
||||
stopbits=1 # 1.5, 2
|
||||
xonxoff=False # True
|
||||
|
||||
and others (see documentation of serial.Serial)
|
||||
"""
|
||||
scheme = 'serial'
|
||||
PARITY_NAMES = {name[0]: name for name in ['NONE', 'ODD', 'EVEN', 'MASK', 'SPACE']}
|
||||
|
||||
def __init__(self, uri, *args, **kwargs):
|
||||
if Serial is None:
|
||||
raise ConfigError('pyserial is not installed')
|
||||
super().__init__(uri, *args, **kwargs)
|
||||
self.uri = uri
|
||||
if uri.startswith('serial://'):
|
||||
# should be the case always
|
||||
uri = uri[9:]
|
||||
uri = uri.split('?', 1)
|
||||
dev = uri[0]
|
||||
try:
|
||||
options = dict((kv.split('=') for kv in uri[1].split('+')))
|
||||
except IndexError: # no uri[1], no options
|
||||
options = {}
|
||||
except ValueError as e:
|
||||
raise ConfigError('illegal serial options') from e
|
||||
parity = options.pop('parity', None) # only parity is to be treated as text
|
||||
for k, v in options.items():
|
||||
try:
|
||||
options[k] = ast.literal_eval(v.title()) # title(): turn false/true into False/True
|
||||
except ValueError:
|
||||
pass
|
||||
if parity is not None:
|
||||
name = parity.upper()
|
||||
fullname = self.PARITY_NAMES[name[0]]
|
||||
if not fullname.startswith(name):
|
||||
raise ConfigError('illegal parity: %s' % parity)
|
||||
options['parity'] = name[0]
|
||||
if 'timeout' not in options:
|
||||
options['timeout'] = self.timeout
|
||||
try:
|
||||
self.connection = Serial(dev, **options)
|
||||
except ValueError as e:
|
||||
raise ConfigError(e) from None
|
||||
# TODO: turn exceptions into ConnectionFailedError, where a retry makes sense
|
||||
|
||||
def disconnect(self):
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
self.connection = None
|
||||
|
||||
def send(self, data):
|
||||
"""send data (bytes!)"""
|
||||
self.connection.write(data)
|
||||
|
||||
def flush_recv(self):
|
||||
result = self._rxbuffer + self.connection.read(self.connection.in_waiting)
|
||||
self._rxbuffer = b''
|
||||
return result
|
||||
|
||||
def recv(self):
|
||||
"""return bytes received within 1 sec"""
|
||||
if not self.connection: # disconnect() might have been called in between
|
||||
raise ConnectionClosed()
|
||||
n = self.connection.in_waiting
|
||||
if n:
|
||||
return self.connection.read(n)
|
||||
data = self.connection.read(1)
|
||||
return data + self.connection.read(self.connection.in_waiting)
|
||||
190
frappy/lib/classdoc.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
from textwrap import indent
|
||||
|
||||
from frappy.modules import Command, HasProperties, Module, Parameter, Property
|
||||
|
||||
|
||||
def indent_description(p):
|
||||
"""indent lines except first one"""
|
||||
return indent(p.description, ' ').replace(' ', '', 1)
|
||||
|
||||
|
||||
def fmt_param(name, param):
|
||||
desc = indent_description(param)
|
||||
if '(' in desc[0:2]:
|
||||
dtinfo = ''
|
||||
else:
|
||||
dtinfo = [short_doc(param.datatype), 'rd' if param.readonly else 'wr',
|
||||
None if param.export else 'hidden']
|
||||
dtinfo = '*(%s)* ' % ', '.join(filter(None, dtinfo))
|
||||
return '- **%s** - %s%s\n' % (name, dtinfo, desc)
|
||||
|
||||
|
||||
def fmt_command(name, command):
|
||||
desc = indent_description(command)
|
||||
if '(' in desc[0:2]:
|
||||
dtinfo = '' # note: we expect that desc contains argument list
|
||||
else:
|
||||
dtinfo = '*%s*' % short_doc(command.datatype) + ' -%s ' % ('' if command.export else ' *(hidden)*')
|
||||
return '- **%s**\\ %s%s\n' % (name, dtinfo, desc)
|
||||
|
||||
|
||||
def fmt_property(name, prop):
|
||||
desc = indent_description(prop)
|
||||
if '(' in desc[0:2]:
|
||||
dtinfo = ''
|
||||
else:
|
||||
dtinfo = [short_doc(prop.datatype), None if prop.export else 'hidden']
|
||||
dtinfo = ', '.join(filter(None, dtinfo))
|
||||
if dtinfo:
|
||||
dtinfo = '*(%s)* ' % dtinfo
|
||||
return '- **%s** - %s%s\n' % (name, dtinfo, desc)
|
||||
|
||||
|
||||
SIMPLETYPES = {
|
||||
'FloatRange': 'float',
|
||||
'ScaledInteger': 'float',
|
||||
'IntRange': 'int',
|
||||
'BlobType': 'bytes',
|
||||
'StringType': 'str',
|
||||
'TextType': 'str',
|
||||
'BoolType': 'bool',
|
||||
'StructOf': 'dict',
|
||||
}
|
||||
|
||||
|
||||
def short_doc(datatype, internal=False):
|
||||
# pylint: disable=possibly-unused-variable
|
||||
|
||||
def doc_EnumType(dt):
|
||||
return 'one of %s' % str(tuple(dt._enum.keys()))
|
||||
|
||||
def doc_ArrayOf(dt):
|
||||
return 'array of %s' % short_doc(dt.members, True)
|
||||
|
||||
def doc_TupleOf(dt):
|
||||
return 'tuple of (%s)' % ', '.join(short_doc(m, True) for m in dt.members)
|
||||
|
||||
def doc_CommandType(dt):
|
||||
argument = short_doc(dt.argument, True) if dt.argument else ''
|
||||
result = ' -> %s' % short_doc(dt.result, True) if dt.result else ''
|
||||
return '(%s)%s' % (argument, result) # return argument list only
|
||||
|
||||
def doc_NoneOr(dt):
|
||||
other = short_doc(dt.other, True)
|
||||
return '%s or None' % other if other else None
|
||||
|
||||
def doc_OrType(dt):
|
||||
types = [short_doc(t, True) for t in dt.types]
|
||||
if None in types: # type is anyway broad: no doc
|
||||
return None
|
||||
return ' or '.join(types)
|
||||
|
||||
def doc_Stub(dt):
|
||||
return dt.name.replace('Type', '').replace('Range', '').lower()
|
||||
|
||||
def doc_BLOBType(dt):
|
||||
return 'byte array'
|
||||
|
||||
clsname = type(datatype).__name__
|
||||
result = SIMPLETYPES.get(clsname)
|
||||
if result:
|
||||
return result
|
||||
fun = locals().get('doc_' + clsname)
|
||||
if fun:
|
||||
return fun(datatype)
|
||||
return clsname if internal else None # broad types like ValueType: no doc
|
||||
|
||||
|
||||
def append_to_doc(cls, lines, itemcls, name, attrname, fmtfunc):
|
||||
"""add information about some items to the doc
|
||||
|
||||
:param cls: the class with the doc string to be extended
|
||||
:param lines: content of the docstring, as lines
|
||||
:param itemcls: the class of the attribute to be collected, a tuple of classes is also allowed.
|
||||
:param attrname: the name of the attribute dict to look for
|
||||
:param name: the name of the items to be collected (used for the title and for the tags)
|
||||
:param fmtfunc: a function returning a formatted item to be displayed, including line feed at end
|
||||
or an empty string to suppress output for this item
|
||||
:type fmtfunc: function(key, value)
|
||||
|
||||
rules, assuming name='properties':
|
||||
|
||||
- if the docstring contains ``{properties}``, new properties are inserted here
|
||||
- if the docstring contains ``{all properties}``, all properties are inserted here
|
||||
- if the docstring contains ``{no properties}``, no properties are inserted
|
||||
|
||||
only the first appearance of a tag above is considered
|
||||
"""
|
||||
doc = '\n'.join(lines)
|
||||
title = 'SECoP %s' % name.title()
|
||||
allitems = getattr(cls, attrname, {})
|
||||
fmtdict = {n: fmtfunc(n, p) for n, p in allitems.items() if isinstance(p, itemcls)}
|
||||
head, _, tail = doc.partition('{all %s}' % name)
|
||||
clsset = set()
|
||||
if tail: # take all
|
||||
fmted = fmtdict.values()
|
||||
else:
|
||||
head, _, tail = doc.partition('{%s}' % name)
|
||||
if not tail:
|
||||
head, _, tail = doc.partition('{no %s}' % name)
|
||||
if tail: # add no information
|
||||
return
|
||||
# no tag found: append to the end
|
||||
|
||||
fmted = []
|
||||
for key, formatted_item in fmtdict.items():
|
||||
if not formatted_item:
|
||||
continue
|
||||
# find where item is defined or modified
|
||||
refcls = None
|
||||
for base in cls.__mro__:
|
||||
p = getattr(base, attrname, {}).get(key)
|
||||
if isinstance(p, itemcls):
|
||||
if fmtfunc(key, p) == formatted_item:
|
||||
refcls = base
|
||||
else:
|
||||
break
|
||||
if refcls == cls:
|
||||
# definition in cls is new or modified
|
||||
fmted.append(formatted_item)
|
||||
else:
|
||||
# definition of last modification in refcls
|
||||
clsset.add(refcls)
|
||||
if fmted:
|
||||
if clsset:
|
||||
fmted.append('- see also %s\n' % (', '.join(':class:`%s.%s`' % (c.__module__, c.__name__)
|
||||
for c in cls.__mro__ if c in clsset)))
|
||||
|
||||
doc = '%s\n\n:%s: %s\n\n%s' % (head, title, ' '.join(fmted), tail)
|
||||
lines[:] = doc.split('\n')
|
||||
|
||||
|
||||
def class_doc_handler(app, what, name, cls, options, lines):
|
||||
if what == 'class':
|
||||
if issubclass(cls, HasProperties):
|
||||
append_to_doc(cls, lines, Property, 'properties', 'propertyDict', fmt_property)
|
||||
if issubclass(cls, Module):
|
||||
append_to_doc(cls, lines, Parameter, 'parameters', 'accessibles', fmt_param)
|
||||
append_to_doc(cls, lines, Command, 'commands', 'accessibles', fmt_command)
|
||||
334
frappy/lib/enum.py
Normal file
@@ -0,0 +1,334 @@
|
||||
# -*- 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Enum class"""
|
||||
|
||||
|
||||
__ALL__ = ['Enum']
|
||||
|
||||
|
||||
class EnumMember:
|
||||
"""represents one member of an Enum
|
||||
|
||||
has an int-type value and attributes 'name' and 'value'
|
||||
"""
|
||||
__slots__ = ['name', 'value', 'enum']
|
||||
|
||||
def __init__(self, enum, name, value):
|
||||
if not isinstance(enum, Enum):
|
||||
raise TypeError('1st Argument must be an instance of class Enum()')
|
||||
self.value = int(value)
|
||||
self.enum = enum
|
||||
self.name = name or 'unnamed'
|
||||
|
||||
# to behave like an int for comparisons
|
||||
def __cmp__(self, other):
|
||||
if isinstance(other, EnumMember):
|
||||
other = other.value
|
||||
if isinstance(other, str):
|
||||
if other in self.enum:
|
||||
other = self.enum[other].value
|
||||
try:
|
||||
other = int(other)
|
||||
except Exception:
|
||||
# raise TypeError('%r can not be compared to %r!' %(other, self))
|
||||
return -1 # XXX:!
|
||||
if self.value < other:
|
||||
return -1
|
||||
if self.value > other:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def __lt__(self, other):
|
||||
return self.__cmp__(other.value if isinstance(other, EnumMember) else other) == -1
|
||||
|
||||
def __le__(self, other):
|
||||
return self.__cmp__(other.value if isinstance(other, EnumMember) else other) < 1
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, EnumMember):
|
||||
return other.value == self.value
|
||||
if isinstance(other, int):
|
||||
return other == self.value
|
||||
# compare by name (for (in)equality only)
|
||||
if isinstance(other, str):
|
||||
if other in self.enum:
|
||||
return self.name == other
|
||||
return False
|
||||
return self.__cmp__(other.value if isinstance(other, EnumMember) else other) == 0
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __ge__(self, other):
|
||||
return self.__cmp__(other.value if isinstance(other, EnumMember) else other) > -1
|
||||
|
||||
def __gt__(self, other):
|
||||
return self.__cmp__(other.value if isinstance(other, EnumMember) else other) == 1
|
||||
|
||||
# to be useful in indexing
|
||||
def __hash__(self):
|
||||
return self.value.__hash__()
|
||||
|
||||
# be read-only (except during initialization)
|
||||
def __setattr__(self, key, value):
|
||||
if key in self.__slots__ and not getattr(self, 'name', None):
|
||||
return object.__setattr__(self, key, value)
|
||||
raise TypeError('Modifying EnumMember\'s is not allowed!')
|
||||
|
||||
# allow access to other EnumMembers (via the Enum)
|
||||
def __getattr__(self, key):
|
||||
enum = object.__getattribute__(self, 'enum')
|
||||
if key in enum:
|
||||
return enum[key]
|
||||
return object.__getattribute__(self, key)
|
||||
|
||||
# be human readable (for debugging)
|
||||
def __repr__(self):
|
||||
return '<%s%s (%d)>' % (self.enum.name + '.' if self.enum.name else '', self.name, self.value)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
# numeric operations: delegate to int. Do we really need any of those?
|
||||
def __add__(self, other):
|
||||
return self.value.__add__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __sub__(self, other):
|
||||
return self.value.__sub__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __mul__(self, other):
|
||||
return self.value.__mul__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __truediv__(self, other):
|
||||
return self.value.__truediv__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __floordiv__(self, other):
|
||||
return self.value.__floordiv__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __mod__(self, other):
|
||||
return self.value.__mod__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __divmod__(self, other):
|
||||
return self.value.__divmod__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __pow__(self, other, *args):
|
||||
return self.value.__pow__(other, *args)
|
||||
|
||||
def __lshift__(self, other):
|
||||
return self.value.__lshift__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rshift__(self, other):
|
||||
return self.value.__rshift__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __radd__(self, other):
|
||||
return self.value.__radd__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rsub__(self, other):
|
||||
return self.value.__rsub__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rmul__(self, other):
|
||||
return self.value.__rmul__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rtruediv__(self, other):
|
||||
return self.value.__rtruediv__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rfloordiv__(self, other):
|
||||
return self.value.__rfloordiv__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rmod__(self, other):
|
||||
return self.value.__rmod__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rdivmod__(self, other):
|
||||
return self.value.__rdivmod__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rpow__(self, other, *args):
|
||||
return self.value.__rpow__(other, *args)
|
||||
|
||||
def __rlshift__(self, other):
|
||||
return self.value.__rlshift__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rrshift__(self, other):
|
||||
return self.value.__rrshift__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
# logical operations
|
||||
def __and__(self, other):
|
||||
return self.value.__and__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __xor__(self, other):
|
||||
return self.value.__xor__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __or__(self, other):
|
||||
return self.value.__or__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rand__(self, other):
|
||||
return self.value.__rand__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __rxor__(self, other):
|
||||
return self.value.__rxor__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
def __ror__(self, other):
|
||||
return self.value.__ror__(other.value if isinstance(other, EnumMember) else other)
|
||||
|
||||
# other stuff
|
||||
def __neg__(self):
|
||||
return self.value.__neg__()
|
||||
|
||||
def __pos__(self):
|
||||
return self.value.__pos__()
|
||||
|
||||
def __abs__(self):
|
||||
return self.value.__abs__()
|
||||
|
||||
def __invert__(self):
|
||||
return self.value.__invert__()
|
||||
|
||||
def __int__(self):
|
||||
return self.value.__int__()
|
||||
|
||||
def __float__(self):
|
||||
return self.value.__float__()
|
||||
|
||||
def __index__(self):
|
||||
return self.value.__index__()
|
||||
|
||||
# note: we do not implement the __i*__ methods as they modify our value
|
||||
# inplace and we want to have a const
|
||||
def __forbidden__(self, *args):
|
||||
raise TypeError('Operation is forbidden!')
|
||||
__iadd__ = __isub__ = __imul__ = __idiv__ = __itruediv__ = __ifloordiv__ = \
|
||||
__imod__ = __ipow__ = __ilshift__ = __irshift__ = __iand__ = \
|
||||
__ixor__ = __ior__ = __forbidden__
|
||||
|
||||
|
||||
class Enum(dict):
|
||||
"""The Enum class
|
||||
|
||||
use instance of this like this:
|
||||
>>> status = Enum('status', idle=1, busy=2, error=3)
|
||||
|
||||
you may create an extended Enum:
|
||||
>>> moveable_status = Enum(status, alarm=5)
|
||||
>>> yet_another_enum = Enum('X', dict(a=1, b=2), c=3)
|
||||
last example 'extends' the definition given by the dict with c=3.
|
||||
|
||||
accessing the members:
|
||||
>>> status['idle'] == status.idle == status('idle')
|
||||
>>> status[1] == status.idle == status(1)
|
||||
|
||||
Each member can be used like an int, so:
|
||||
>>> status.idle == 1 is True
|
||||
>>> status.error +5
|
||||
|
||||
You can neither modify members nor Enums.
|
||||
You only can create an extended Enum.
|
||||
"""
|
||||
name = ''
|
||||
|
||||
def __init__(self, name='', parent=None, **kwds):
|
||||
super().__init__()
|
||||
if isinstance(name, (dict, Enum)) and parent is None:
|
||||
# swap if only parent is given as positional argument
|
||||
name, parent = '', name
|
||||
# parent may be dict, or Enum....
|
||||
if not name:
|
||||
if isinstance(parent, Enum):
|
||||
# if name was not given, use that of the parent
|
||||
# this means, an extended Enum behaves like the parent
|
||||
# THIS MAY BE CONFUSING SOMETIMES!
|
||||
name = parent.name
|
||||
# else:
|
||||
# raise TypeError('Enum instances need a name or an Enum parent!')
|
||||
if not isinstance(name, str):
|
||||
raise TypeError('1st argument to Enum must be a name or an Enum!')
|
||||
|
||||
names = set()
|
||||
values = set()
|
||||
|
||||
# pylint: disable=dangerous-default-value
|
||||
def add(self, k, v, names=names, value=values):
|
||||
"""helper for creating the enum members"""
|
||||
if v is None:
|
||||
# sugar: take the next free number if value was None
|
||||
v = max(values or [0]) + 1
|
||||
# sugar: if value is a name of another member,
|
||||
# auto-assign the smallest free number which is bigger
|
||||
# then that assigned to that name
|
||||
if v in names:
|
||||
v = self[v].value
|
||||
while v in values:
|
||||
v += 1
|
||||
|
||||
# check that the value is an int
|
||||
_v = int(v)
|
||||
if _v != v:
|
||||
raise TypeError('Values must be integers!')
|
||||
v = _v
|
||||
|
||||
# check for duplicates
|
||||
if k in names:
|
||||
raise TypeError('duplicate name %r' % k)
|
||||
if v in values:
|
||||
raise TypeError('duplicate value %d (key=%r)' % (v, k))
|
||||
|
||||
# remember it
|
||||
self[v] = self[k] = EnumMember(self, k, v)
|
||||
names.add(k)
|
||||
values.add(v)
|
||||
|
||||
if isinstance(parent, Enum):
|
||||
for m in parent.members:
|
||||
add(self, m.name, m.value)
|
||||
elif isinstance(parent, dict):
|
||||
for k, v in parent.items():
|
||||
add(self, k, v)
|
||||
elif parent is not None:
|
||||
raise TypeError('parent (if given) MUST be a dict or an Enum!')
|
||||
for k, v in kwds.items():
|
||||
add(self, k, v)
|
||||
self.members = tuple(sorted(self[n] for n in names))
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, key):
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError as e:
|
||||
raise AttributeError(str(e)) from None
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if self.name and key != 'name':
|
||||
raise TypeError('Enum %r can not be changed!' % self.name)
|
||||
super().__setattr__(key, value)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if self.name:
|
||||
raise TypeError('Enum %r can not be changed!' % self.name)
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def __delitem__(self, key):
|
||||
raise TypeError('Enum %r can not be changed!' % self.name)
|
||||
|
||||
def __repr__(self):
|
||||
return 'Enum(%r, %s)' % (self.name, ', '.join('%s=%d' % (m.name, m.value) for m in self.members))
|
||||
|
||||
def __call__(self, key):
|
||||
return self[key]
|
||||
141
frappy/lib/multievent.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
ETERNITY = 1e99
|
||||
|
||||
|
||||
class _SingleEvent:
|
||||
"""Single Event
|
||||
|
||||
remark: :meth:`wait` is not implemented on purpose
|
||||
"""
|
||||
def __init__(self, multievent, timeout, name=None):
|
||||
self.multievent = multievent
|
||||
self.multievent.clear_(self)
|
||||
self.name = name
|
||||
if timeout is None:
|
||||
self.deadline = ETERNITY
|
||||
else:
|
||||
self.deadline = time.monotonic() + timeout
|
||||
|
||||
def clear(self):
|
||||
self.multievent.clear_(self)
|
||||
|
||||
def set(self):
|
||||
self.multievent.set_(self)
|
||||
|
||||
def is_set(self):
|
||||
return self in self.multievent.events
|
||||
|
||||
|
||||
class MultiEvent(threading.Event):
|
||||
"""Class implementing multi event objects."""
|
||||
|
||||
def __init__(self, default_timeout=None):
|
||||
self.events = set()
|
||||
self._lock = threading.Lock()
|
||||
self.default_timeout = default_timeout or None # treat 0 as None
|
||||
self.name = None # default event name
|
||||
self._actions = [] # actions to be executed on trigger
|
||||
super().__init__()
|
||||
|
||||
def new(self, timeout=None, name=None):
|
||||
"""create a single event like object"""
|
||||
return _SingleEvent(self, timeout or self.default_timeout,
|
||||
name or self.name or '<unnamed>')
|
||||
|
||||
def set(self):
|
||||
raise ValueError('a multievent must not be set directly')
|
||||
|
||||
def clear(self):
|
||||
raise ValueError('a multievent must not be cleared directly')
|
||||
|
||||
def is_set(self):
|
||||
return not self.events
|
||||
|
||||
def set_(self, event):
|
||||
"""internal: remove event from the event list"""
|
||||
with self._lock:
|
||||
self.events.discard(event)
|
||||
if self.events:
|
||||
return
|
||||
try:
|
||||
for action in self._actions:
|
||||
action()
|
||||
except Exception:
|
||||
pass # we silently ignore errors here
|
||||
self._actions = []
|
||||
super().set()
|
||||
|
||||
def clear_(self, event):
|
||||
"""internal: add event to the event list"""
|
||||
with self._lock:
|
||||
self.events.add(event)
|
||||
super().clear()
|
||||
|
||||
def deadline(self):
|
||||
deadline = 0
|
||||
for event in self.events:
|
||||
deadline = max(event.deadline, deadline)
|
||||
return None if deadline == ETERNITY else deadline
|
||||
|
||||
def wait(self, timeout=None):
|
||||
"""wait for all events being set or timed out"""
|
||||
if not self.events: # do not wait if events are empty
|
||||
return True
|
||||
deadline = self.deadline()
|
||||
if deadline is not None:
|
||||
deadline -= time.monotonic()
|
||||
timeout = deadline if timeout is None else min(deadline, timeout)
|
||||
if timeout <= 0:
|
||||
return False
|
||||
return super().wait(timeout)
|
||||
|
||||
def waiting_for(self):
|
||||
return set(event.name for event in self.events)
|
||||
|
||||
def get_trigger(self, timeout=None, name=None):
|
||||
"""create a new single event and return its set method
|
||||
|
||||
as a convenience method
|
||||
"""
|
||||
return self.new(timeout, name).set
|
||||
|
||||
def queue(self, action):
|
||||
"""add an action to the queue of actions to be executed at end
|
||||
|
||||
:param action: a function, to be executed after the last event is triggered,
|
||||
and before the multievent is set
|
||||
|
||||
- if no events are waiting, the actions are executed immediately
|
||||
- if an action raises an exception, it is silently ignore and further
|
||||
actions in the queue are skipped
|
||||
- if this is not desired, the action should handle errors by itself
|
||||
"""
|
||||
with self._lock:
|
||||
self._actions.append(action)
|
||||
if self.is_set():
|
||||
self.set_(None)
|
||||
56
frappy/lib/pidfile.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define pidfile helpers"""
|
||||
|
||||
import atexit
|
||||
import os
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
def read_pidfile(pidfile):
|
||||
"""read the given pidfile, return the pid as an int
|
||||
|
||||
or None upon errors (file not existing)"""
|
||||
try:
|
||||
with open(pidfile, 'r', encoding='utf-8') as f:
|
||||
return int(f.read())
|
||||
except (OSError, IOError):
|
||||
return None
|
||||
|
||||
|
||||
def remove_pidfile(pidfile):
|
||||
"""remove the given pidfile, typically at end of the process"""
|
||||
os.remove(pidfile)
|
||||
|
||||
|
||||
def write_pidfile(pidfile, pid):
|
||||
"""write the given pid to the given pidfile"""
|
||||
with open(pidfile, 'w', encoding='utf-8') as f:
|
||||
f.write('%d\n' % pid)
|
||||
atexit.register(remove_pidfile, pidfile)
|
||||
|
||||
|
||||
def check_pidfile(pidfile):
|
||||
"""check if the process from a given pidfile is still running"""
|
||||
pid = read_pidfile(pidfile)
|
||||
return False if pid is None else psutil.pid_exists(pid)
|
||||
58
frappy/lib/py35compat.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""workaround for python versions older than 3.6
|
||||
|
||||
``Object`` must be inherited for classes needing support for
|
||||
__init_subclass__ and __set_name__
|
||||
"""
|
||||
|
||||
|
||||
if hasattr(object, '__init_subclass__'):
|
||||
class Object:
|
||||
pass
|
||||
else:
|
||||
class PEP487Metaclass(type):
|
||||
# support for __set_name__ and __init_subclass__ for older python versions
|
||||
# slightly modified from PEP487 doc
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if len(args) != 3:
|
||||
return super().__new__(cls, *args)
|
||||
name, bases, ns = args
|
||||
init = ns.get('__init_subclass__')
|
||||
if callable(init):
|
||||
ns['__init_subclass__'] = classmethod(init)
|
||||
newtype = super().__new__(cls, name, bases, ns)
|
||||
for k, v in newtype.__dict__.items():
|
||||
func = getattr(v, '__set_name__', None)
|
||||
if func is not None:
|
||||
func(newtype, k)
|
||||
if bases:
|
||||
super(newtype, newtype).__init_subclass__(**kwargs) # pylint: disable=bad-super-call
|
||||
return newtype
|
||||
|
||||
def __init__(cls, name, bases, ns, **kwargs):
|
||||
super().__init__(name, bases, ns)
|
||||
|
||||
class Object(metaclass=PEP487Metaclass):
|
||||
@classmethod
|
||||
def __init_subclass__(cls, *args, **kwargs):
|
||||
pass
|
||||
192
frappy/lib/sequence.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Georg Brandl <g.brandl@fz-juelich.de>
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
"""Utilities for modules that require sequenced actions on value change."""
|
||||
|
||||
|
||||
from time import sleep
|
||||
|
||||
from frappy.errors import IsBusyError
|
||||
from frappy.lib import mkthread
|
||||
|
||||
|
||||
class Namespace:
|
||||
pass
|
||||
|
||||
|
||||
class Step:
|
||||
|
||||
def __init__(self, desc, waittime, func, *args, **kwds):
|
||||
self.desc = desc
|
||||
self.waittime = waittime
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwds = kwds
|
||||
|
||||
|
||||
class SequencerMixin:
|
||||
"""Mixin for worker classes that need to execute a sequence of actions,
|
||||
including waits, that exceeds the usual Tango timeout (about 3 seconds)
|
||||
and should be executed asynchronously.
|
||||
|
||||
.. automethod:: init_sequencer
|
||||
|
||||
.. automethod:: start_sequence
|
||||
|
||||
.. automethod:: seq_is_alive
|
||||
|
||||
.. method:: _ext_state()
|
||||
|
||||
Implement this to return a custom state tuple when the sequence is
|
||||
not active.
|
||||
"""
|
||||
|
||||
def init_sequencer(self, fault_on_error=True, fault_on_stop=False):
|
||||
"""Initialize the sequencer. Must be called in the worker's init().
|
||||
|
||||
*fault_on_error* and *fault_on_stop* control the behavior when
|
||||
exceptions are raised, or stop is activated, during the sequence, see
|
||||
below.
|
||||
"""
|
||||
# thread variable init
|
||||
self._seq_thread = None
|
||||
self._seq_fault_on_error = fault_on_error
|
||||
self._seq_fault_on_stop = fault_on_stop
|
||||
self._seq_stopflag = False
|
||||
self._seq_phase = ''
|
||||
self._seq_error = None
|
||||
self._seq_stopped = None
|
||||
|
||||
def start_sequence(self, seq, **store_init):
|
||||
"""Start the sequence, given the list of steps.
|
||||
|
||||
Each step should be a ``Step`` instance:
|
||||
|
||||
Step('phase description', waittime, callable)
|
||||
|
||||
where the callable should take one argument and execute an atomic step
|
||||
of the sequence. The description is added to the status string while
|
||||
the step is active. The waittime is a sleep after the step completes.
|
||||
|
||||
As long as the callable returns a true value, the step is repeated.
|
||||
|
||||
The argument to the step callable is a featureless "store" object on
|
||||
which data can be transferred between steps. This is provided so that
|
||||
steps don't save temporary variables on ``self``. Keyword arguments
|
||||
given to ``start_sequence`` are added to the store at the beginning.
|
||||
|
||||
**Error handling**
|
||||
|
||||
If *fault_on_error* in ``init_sequencer`` is true and an exception is
|
||||
raised during an atomic step, the module goes into an ERROR state
|
||||
because it cannot be ensured that further actions will be safe to
|
||||
execute. A manual reset is required.
|
||||
|
||||
Otherwise, the module goes into the WARN state and can be started
|
||||
again normally.
|
||||
|
||||
**Stop handling**
|
||||
|
||||
Between each atomic step, the "stop" flag for the sequence is checked,
|
||||
which is set by the mixin's ``Stop`` method.
|
||||
|
||||
The *fault_on_stop* argument in ``init_sequencer`` controls which state
|
||||
the module enters when the sequence is interrupted by a stop. Here,
|
||||
the default is to only go into ALARM.
|
||||
"""
|
||||
if self.seq_is_alive():
|
||||
raise IsBusyError('move sequence already in progress')
|
||||
|
||||
self._seq_stopflag = False
|
||||
self._seq_error = self._seq_stopped = None
|
||||
|
||||
self._seq_thread = mkthread(self._seq_thread_outer, seq, store_init)
|
||||
|
||||
def seq_is_alive(self):
|
||||
"""Can be called to check if a sequence is currently running."""
|
||||
return self._seq_thread and self._seq_thread.is_alive()
|
||||
|
||||
def read_status(self):
|
||||
if self.seq_is_alive():
|
||||
return self.Status.BUSY, 'moving: ' + self._seq_phase
|
||||
if self._seq_error:
|
||||
if self._seq_fault_on_error:
|
||||
return self.Status.ERROR, self._seq_error
|
||||
return self.Status.WARN, self._seq_error
|
||||
if self._seq_stopped:
|
||||
if self._seq_fault_on_stop:
|
||||
return self.Status.ERROR, self._seq_stopped
|
||||
return self.Status.WARN, self._seq_stopped
|
||||
if hasattr(self, 'readHwStatus'):
|
||||
return self.readHwStatus()
|
||||
return self.Status.IDLE, ''
|
||||
|
||||
def stop(self):
|
||||
if self.seq_is_alive():
|
||||
self._seq_stopflag = True
|
||||
|
||||
def _seq_thread_outer(self, seq, store_init):
|
||||
try:
|
||||
self._seq_thread_inner(seq, store_init)
|
||||
except Exception as e:
|
||||
self.log.exception('unhandled error in sequence thread: %s', e)
|
||||
self._seq_error = str(e)
|
||||
finally:
|
||||
self._seq_thread = None
|
||||
self.doPoll()
|
||||
|
||||
def _seq_thread_inner(self, seq, store_init):
|
||||
store = Namespace()
|
||||
store.__dict__.update(store_init)
|
||||
self.log.debug('sequence: starting, values %s', store_init)
|
||||
|
||||
for step in seq:
|
||||
self._seq_phase = step.desc
|
||||
self.log.debug('sequence: entering phase: %s', step.desc)
|
||||
try:
|
||||
i = 0
|
||||
while True:
|
||||
store.i = i
|
||||
result = step.func(store, *step.args)
|
||||
if self._seq_stopflag:
|
||||
if result:
|
||||
self._seq_stopped = 'stopped while %s' % step.desc
|
||||
else:
|
||||
self._seq_stopped = 'stopped after %s' % step.desc
|
||||
cleanup_func = step.kwds.get('cleanup', None)
|
||||
if callable(cleanup_func):
|
||||
try:
|
||||
cleanup_func(store, result, *step.args)
|
||||
except Exception as e:
|
||||
self.log.exception(e)
|
||||
raise
|
||||
return
|
||||
sleep(step.waittime)
|
||||
if not result:
|
||||
break
|
||||
i += 1
|
||||
except Exception as e:
|
||||
self.log.exception(
|
||||
'error in sequence step %r: %s', step.desc, e)
|
||||
self._seq_error = 'during %s: %s' % (step.desc, e)
|
||||
break
|
||||
321
frappy/lib/statemachine.py
Normal file
@@ -0,0 +1,321 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""a simple, but powerful state machine
|
||||
|
||||
Mechanism
|
||||
---------
|
||||
|
||||
The code for the state machine is NOT to be implemented as a subclass
|
||||
of StateMachine, but usually as functions or methods of an other object.
|
||||
The created state object may hold variables needed for the state.
|
||||
A state function may return either:
|
||||
- a function for the next state to transition to
|
||||
- Retry(<delay>) to keep the state and call the
|
||||
- or `None` for finishing
|
||||
|
||||
|
||||
Initialisation Code
|
||||
-------------------
|
||||
|
||||
For code to be called only after a state transition, use stateobj.init.
|
||||
|
||||
def state_x(stateobj):
|
||||
if stateobj.init:
|
||||
... code to be execute only after entering state x ...
|
||||
... further code ...
|
||||
|
||||
|
||||
Cleanup Function
|
||||
----------------
|
||||
|
||||
cleanup=<cleanup function> as argument in StateMachine.__init__ or .start
|
||||
defines a cleanup function to be called whenever the machine is stopped or
|
||||
an error is raised in a state function. A cleanup function may return
|
||||
either None for finishing or a further state function for continuing.
|
||||
In case of stop or restart, this return value is ignored.
|
||||
|
||||
|
||||
State Specific Cleanup Code
|
||||
---------------------------
|
||||
|
||||
To execute state specific cleanup, the cleanup may examine the current state
|
||||
(stateobj.state) in order to decide what to be done.
|
||||
|
||||
If a need arises, a future extension to this library may support specific
|
||||
cleanup functions by means of a decorator adding the specific cleanup function
|
||||
as an attribute to the state function.
|
||||
|
||||
|
||||
Threaded Use
|
||||
------------
|
||||
|
||||
On start, a thread is started, which is waiting for a trigger event when the
|
||||
machine is not active. For test purposes or special needs, the thread creation
|
||||
may be disabled. :meth:`cycle` must be called periodically in this case.
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
import queue
|
||||
from logging import getLogger
|
||||
from frappy.lib import mkthread, UniqueObject
|
||||
|
||||
|
||||
Stop = UniqueObject('Stop')
|
||||
Restart = UniqueObject('Restart')
|
||||
|
||||
|
||||
class Retry:
|
||||
def __init__(self, delay=None):
|
||||
self.delay = delay
|
||||
|
||||
|
||||
class StateMachine:
|
||||
"""a simple, but powerful state machine"""
|
||||
# class attributes are not allowed to be overriden by kwds of __init__ or :meth:`start`
|
||||
start_time = None # the time of last start
|
||||
transition_time = None # the last time when the state changed
|
||||
state = None # the current state
|
||||
now = None
|
||||
init = True
|
||||
stopped = False
|
||||
last_error = None # last exception raised or Stop or Restart
|
||||
_last_time = 0
|
||||
|
||||
def __init__(self, state=None, logger=None, threaded=True, **kwds):
|
||||
"""initialize state machine
|
||||
|
||||
:param state: if given, this is the first state
|
||||
:param logger: an optional logger
|
||||
:param threaded: whether a thread should be started (default: True)
|
||||
:param kwds: any attributes for the state object
|
||||
"""
|
||||
self.default_delay = 0.25 # default delay when returning None
|
||||
self.now = time.time() # avoid calling time.time several times per state
|
||||
self.cleanup = self.default_cleanup # default cleanup: finish on error
|
||||
self.log = logger or getLogger('dummy')
|
||||
self._update_attributes(kwds)
|
||||
self._lock = threading.RLock()
|
||||
self._threaded = threaded
|
||||
if threaded:
|
||||
self._thread_queue = queue.Queue()
|
||||
self._idle_event = threading.Event()
|
||||
self._thread = None
|
||||
self._restart = None
|
||||
if state:
|
||||
self.start(state)
|
||||
|
||||
@staticmethod
|
||||
def default_cleanup(state):
|
||||
"""default cleanup
|
||||
|
||||
:param self: the state object
|
||||
:return: None (for custom cleanup functions this might be a new state)
|
||||
"""
|
||||
if state.stopped: # stop or restart
|
||||
verb = 'stopped' if state.stopped is Stop else 'restarted'
|
||||
state.log.debug('%s in state %r', verb, state.status_string)
|
||||
else:
|
||||
state.log.warning('%r raised in state %r', state.last_error, state.status_string)
|
||||
|
||||
def _update_attributes(self, kwds):
|
||||
"""update allowed attributes"""
|
||||
cls = type(self)
|
||||
for key, value in kwds.items():
|
||||
if hasattr(cls, key):
|
||||
raise AttributeError('can not set %s.%s' % (cls.__name__, key))
|
||||
setattr(self, key, value)
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return bool(self.state)
|
||||
|
||||
@property
|
||||
def status_string(self):
|
||||
if self.state is None:
|
||||
return ''
|
||||
doc = self.state.__doc__
|
||||
return doc.split('\n', 1)[0] if doc else self.state.__name__
|
||||
|
||||
@property
|
||||
def state_time(self):
|
||||
"""the time spent already in this state"""
|
||||
return self.now - self.transition_time
|
||||
|
||||
@property
|
||||
def run_time(self):
|
||||
"""time since last (re-)start"""
|
||||
return self.now - self.start_time
|
||||
|
||||
def _new_state(self, state):
|
||||
self.state = state
|
||||
self.init = True
|
||||
self.now = time.time()
|
||||
self.transition_time = self.now
|
||||
self.log.debug('state: %s', self.status_string)
|
||||
|
||||
def cycle(self):
|
||||
"""do one cycle in the thread loop
|
||||
|
||||
:return: a delay or None when idle
|
||||
"""
|
||||
with self._lock:
|
||||
if self.state is None:
|
||||
return None
|
||||
for _ in range(999):
|
||||
self.now = time.time()
|
||||
try:
|
||||
ret = self.state(self)
|
||||
self.init = False
|
||||
if self.stopped:
|
||||
self.last_error = self.stopped
|
||||
self.cleanup(self)
|
||||
self.stopped = False
|
||||
ret = None
|
||||
except Exception as e:
|
||||
self.last_error = e
|
||||
ret = self.cleanup(self)
|
||||
self.log.debug('called %r %sexc=%r', self.cleanup,
|
||||
'ret=%r ' % ret if ret else '', e)
|
||||
if ret is None:
|
||||
self.log.debug('state: None after cleanup')
|
||||
self.state = None
|
||||
self._idle_event.set()
|
||||
return None
|
||||
if callable(ret):
|
||||
self._new_state(ret)
|
||||
continue
|
||||
if isinstance(ret, Retry):
|
||||
if ret.delay == 0:
|
||||
continue
|
||||
if ret.delay is None:
|
||||
return self.default_delay
|
||||
return ret.delay
|
||||
self.last_error = RuntimeError('return value must be callable, Retry(...) or finish')
|
||||
break
|
||||
else:
|
||||
self.last_error = RuntimeError('too many states chained - probably infinite loop')
|
||||
self.cleanup(self)
|
||||
self.state = None
|
||||
return None
|
||||
|
||||
def trigger(self, delay=0):
|
||||
if self._threaded:
|
||||
self._thread_queue.put(delay)
|
||||
|
||||
def _run(self, delay):
|
||||
"""thread loop
|
||||
|
||||
:param delay: delay before first state is called
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
ret = self._thread_queue.get(timeout=delay)
|
||||
if ret is not None:
|
||||
delay = ret
|
||||
continue
|
||||
except queue.Empty:
|
||||
pass
|
||||
delay = self.cycle()
|
||||
|
||||
def _start(self, state, **kwds):
|
||||
self._restart = None
|
||||
self._idle_event.clear()
|
||||
self.last_error = None
|
||||
self.stopped = False
|
||||
self._update_attributes(kwds)
|
||||
self._new_state(state)
|
||||
self.start_time = self.now
|
||||
self._last_time = self.now
|
||||
first_delay = self.cycle() # important: call once (e.g. set status to busy)
|
||||
if self._threaded:
|
||||
if self._thread is None or not self._thread.is_alive():
|
||||
# restart thread if dead (may happen when cleanup failed)
|
||||
if first_delay is not None:
|
||||
self._thread = mkthread(self._run, first_delay)
|
||||
else:
|
||||
self.trigger(first_delay)
|
||||
|
||||
def start(self, state, **kwds):
|
||||
"""start with a new state
|
||||
|
||||
and interrupt the current state
|
||||
the cleanup function will be called with state.stopped=Restart
|
||||
|
||||
:param state: the first state
|
||||
:param kwds: items to put as attributes on the state machine
|
||||
"""
|
||||
self.log.debug('start %r', kwds)
|
||||
if self.state:
|
||||
self.stopped = Restart
|
||||
with self._lock: # wait for running cycle finished
|
||||
if self.stopped: # cleanup is not yet done
|
||||
self.last_error = self.stopped
|
||||
self.cleanup(self) # ignore return state on restart
|
||||
self.stopped = False
|
||||
self._start(state, **kwds)
|
||||
else:
|
||||
self._start(state, **kwds)
|
||||
|
||||
def stop(self):
|
||||
"""stop machine, go to idle state
|
||||
|
||||
the cleanup function will be called with state.stopped=Stop
|
||||
"""
|
||||
self.log.debug('stop')
|
||||
self.stopped = Stop
|
||||
with self._lock:
|
||||
if self.stopped: # cleanup is not yet done
|
||||
self.last_error = self.stopped
|
||||
self.cleanup(self) # ignore return state on restart
|
||||
self.stopped = False
|
||||
self.state = None
|
||||
|
||||
def wait(self, timeout=None):
|
||||
"""wait for state machine being idle"""
|
||||
self._idle_event.wait(timeout)
|
||||
|
||||
def delta(self, mindelta=0):
|
||||
"""helper method for time dependent control
|
||||
|
||||
:param mindelta: minimum time since last call
|
||||
:return: time delta or None when less than min delta time has passed
|
||||
|
||||
to be called from within an state
|
||||
|
||||
Usage:
|
||||
|
||||
def state_x(self, state):
|
||||
delta = state.delta(5)
|
||||
if delta is None:
|
||||
return # less than 5 seconds have passed, we wait for the next cycle
|
||||
# delta is >= 5, and the zero time for delta is set
|
||||
|
||||
# now we can use delta for control calculations
|
||||
|
||||
remark: in the first step after start, state.delta(0) returns nearly 0
|
||||
"""
|
||||
delta = self.now - self._last_time
|
||||
if delta < mindelta:
|
||||
return None
|
||||
self._last_time = self.now
|
||||
return delta
|
||||
171
frappy/logging.py
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
import os
|
||||
from os.path import dirname, join
|
||||
from logging import DEBUG, INFO, addLevelName
|
||||
import mlzlog
|
||||
|
||||
from frappy.lib import generalConfig
|
||||
from frappy.datatypes import BoolType
|
||||
from frappy.properties import Property
|
||||
|
||||
OFF = 99
|
||||
COMLOG = 15
|
||||
addLevelName(COMLOG, 'COMLOG')
|
||||
assert DEBUG < COMLOG < INFO
|
||||
LOG_LEVELS = dict(mlzlog.LOGLEVELS, off=OFF, comlog=COMLOG)
|
||||
LEVEL_NAMES = {v: k for k, v in LOG_LEVELS.items()}
|
||||
|
||||
|
||||
def check_level(level):
|
||||
try:
|
||||
if isinstance(level, str):
|
||||
return LOG_LEVELS[level.lower()]
|
||||
if level in LEVEL_NAMES:
|
||||
return level
|
||||
except KeyError:
|
||||
pass
|
||||
raise ValueError('%r is not a valid level' % level)
|
||||
|
||||
|
||||
class RemoteLogHandler(mlzlog.Handler):
|
||||
"""handler for remote logging"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.subscriptions = {} # dict[modname] of tuple(mobobj, dict [conn] of level)
|
||||
|
||||
def emit(self, record):
|
||||
"""unused"""
|
||||
|
||||
def handle(self, record):
|
||||
modname = record.name.split('.')[-1]
|
||||
try:
|
||||
modobj, subscriptions = self.subscriptions[modname]
|
||||
except KeyError:
|
||||
return
|
||||
for conn, lev in subscriptions.items():
|
||||
if record.levelno >= lev:
|
||||
modobj.DISPATCHER.send_log_msg(
|
||||
conn, modobj.name, LEVEL_NAMES[record.levelno],
|
||||
record.getMessage())
|
||||
|
||||
def set_conn_level(self, modobj, conn, level):
|
||||
level = check_level(level)
|
||||
modobj, subscriptions = self.subscriptions.setdefault(modobj.name, (modobj, {}))
|
||||
if level == OFF:
|
||||
subscriptions.pop(conn, None)
|
||||
else:
|
||||
subscriptions[conn] = level
|
||||
|
||||
def __repr__(self):
|
||||
return 'RemoteLogHandler()'
|
||||
|
||||
|
||||
class LogfileHandler(mlzlog.LogfileHandler):
|
||||
|
||||
def __init__(self, logdir, rootname, max_days=0):
|
||||
self.rootname = rootname
|
||||
self.max_days = max_days
|
||||
super().__init__(logdir, rootname)
|
||||
|
||||
def emit(self, record):
|
||||
if record.levelno != COMLOG:
|
||||
super().emit(record)
|
||||
|
||||
def getChild(self, name):
|
||||
child = type(self)(dirname(self.baseFilename), name, self.max_days)
|
||||
child.setLevel(self.level)
|
||||
return child
|
||||
|
||||
def doRollover(self):
|
||||
super().doRollover()
|
||||
if self.max_days:
|
||||
# keep only the last max_days files
|
||||
with os.scandir(dirname(self.baseFilename)) as it:
|
||||
files = sorted(entry.path for entry in it if entry.name != 'current')
|
||||
for filepath in files[-self.max_days:]:
|
||||
os.remove(filepath)
|
||||
|
||||
|
||||
class ComLogfileHandler(LogfileHandler):
|
||||
"""handler for logging communication"""
|
||||
|
||||
def format(self, record):
|
||||
return '%s %s' % (self.formatter.formatTime(record), record.getMessage())
|
||||
|
||||
|
||||
class HasComlog:
|
||||
"""mixin for modules with comlog"""
|
||||
comlog = Property('whether communication is logged ', BoolType(),
|
||||
default=True, export=False)
|
||||
_comLog = None
|
||||
|
||||
def earlyInit(self):
|
||||
super().earlyInit()
|
||||
if self.comlog and generalConfig.initialized and generalConfig.comlog:
|
||||
self._comLog = mlzlog.Logger('COMLOG.%s' % self.name)
|
||||
self._comLog.handlers[:] = []
|
||||
directory = join(logger.logdir, logger.rootname, 'comlog', self.DISPATCHER.name)
|
||||
self._comLog.addHandler(ComLogfileHandler(
|
||||
directory, self.name, max_days=generalConfig.getint('comlog_days', 7)))
|
||||
return
|
||||
|
||||
def comLog(self, msg, *args, **kwds):
|
||||
self.log.log(COMLOG, msg, *args, **kwds)
|
||||
if self._comLog:
|
||||
self._comLog.info(msg, *args)
|
||||
|
||||
|
||||
class MainLogger:
|
||||
def __init__(self):
|
||||
self.log = None
|
||||
self.logdir = None
|
||||
self.rootname = None
|
||||
self.console_handler = None
|
||||
|
||||
def init(self, console_level='info'):
|
||||
self.rootname = generalConfig.get('logger_root', 'frappy')
|
||||
# set log level to minimum on the logger, effective levels on the handlers
|
||||
# needed also for RemoteLogHandler
|
||||
# modified from mlzlog.initLogging
|
||||
mlzlog.setLoggerClass(mlzlog.MLZLogger)
|
||||
assert self.log is None
|
||||
self.log = mlzlog.log = mlzlog.MLZLogger(self.rootname)
|
||||
|
||||
self.log.setLevel(DEBUG)
|
||||
self.log.addHandler(mlzlog.ColoredConsoleHandler())
|
||||
|
||||
self.logdir = generalConfig.get('logdir', '/tmp/log')
|
||||
if self.logdir:
|
||||
logfile_days = generalConfig.getint('logfile_days')
|
||||
logfile_handler = LogfileHandler(self.logdir, self.rootname, max_days=logfile_days)
|
||||
logfile_handler.setLevel(LOG_LEVELS[generalConfig.get('logfile_level', 'info')])
|
||||
self.log.addHandler(logfile_handler)
|
||||
|
||||
self.log.addHandler(RemoteLogHandler())
|
||||
self.log.handlers[0].setLevel(LOG_LEVELS[console_level])
|
||||
|
||||
|
||||
logger = MainLogger()
|
||||
879
frappy/modules.py
Normal file
@@ -0,0 +1,879 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define base classes for real Modules implemented in the server"""
|
||||
|
||||
|
||||
import time
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from functools import wraps
|
||||
|
||||
from frappy.datatypes import ArrayOf, BoolType, EnumType, FloatRange, \
|
||||
IntRange, StatusType, StringType, TextType, TupleOf, DiscouragedConversion
|
||||
from frappy.errors import BadValueError, CommunicationFailedError, ConfigError, \
|
||||
ProgrammingError, SECoPError, secop_error
|
||||
from frappy.lib import formatException, mkthread, UniqueObject, generalConfig
|
||||
from frappy.lib.enum import Enum
|
||||
from frappy.params import Accessible, Command, Parameter
|
||||
from frappy.properties import HasProperties, Property
|
||||
from frappy.logging import RemoteLogHandler, HasComlog
|
||||
|
||||
generalConfig.set_default('disable_value_range_check', False) # check for problematic value range by default
|
||||
|
||||
Done = UniqueObject('Done')
|
||||
"""a special return value for a read/write function
|
||||
|
||||
indicating that the setter is triggered already"""
|
||||
|
||||
|
||||
class HasAccessibles(HasProperties):
|
||||
"""base class of Module
|
||||
|
||||
joining the class's properties, parameters and commands dicts with
|
||||
those of base classes.
|
||||
wrap read_*/write_* methods
|
||||
(so the dispatcher will get notified of changed values)
|
||||
"""
|
||||
@classmethod
|
||||
def __init_subclass__(cls): # pylint: disable=too-many-branches
|
||||
super().__init_subclass__()
|
||||
# merge accessibles from all sub-classes, treat overrides
|
||||
# for now, allow to use also the old syntax (parameters/commands dict)
|
||||
accessibles = OrderedDict() # dict of accessibles
|
||||
merged_properties = {} # dict of dict of merged properties
|
||||
new_names = [] # list of names of new accessibles
|
||||
override_values = {} # bare values overriding a parameter and methods overriding a command
|
||||
|
||||
for base in reversed(cls.__mro__):
|
||||
for key, value in base.__dict__.items():
|
||||
if isinstance(value, Accessible):
|
||||
value.updateProperties(merged_properties.setdefault(key, {}))
|
||||
if base == cls and key not in accessibles:
|
||||
new_names.append(key)
|
||||
accessibles[key] = value
|
||||
override_values.pop(key, None)
|
||||
elif key in accessibles:
|
||||
override_values[key] = value
|
||||
for aname, aobj in list(accessibles.items()):
|
||||
if aname in override_values:
|
||||
aobj = aobj.copy()
|
||||
value = override_values[aname]
|
||||
if value is None:
|
||||
accessibles.pop(aname)
|
||||
continue
|
||||
aobj.merge(merged_properties[aname])
|
||||
aobj.override(value)
|
||||
# replace the bare value by the created accessible
|
||||
setattr(cls, aname, aobj)
|
||||
else:
|
||||
aobj.merge(merged_properties[aname])
|
||||
accessibles[aname] = aobj
|
||||
|
||||
# rebuild order: (1) inherited items, (2) items from paramOrder, (3) new accessibles
|
||||
# move (2) to the end
|
||||
paramOrder = cls.__dict__.get('paramOrder', ())
|
||||
for aname in paramOrder:
|
||||
if aname in accessibles:
|
||||
accessibles.move_to_end(aname)
|
||||
# ignore unknown names
|
||||
# move (3) to the end
|
||||
for aname in new_names:
|
||||
if aname not in paramOrder:
|
||||
accessibles.move_to_end(aname)
|
||||
# note: for python < 3.6 the order of inherited items is not ensured between
|
||||
# declarations within the same class
|
||||
cls.accessibles = accessibles
|
||||
|
||||
# Correct naming of EnumTypes
|
||||
# moved to Parameter.__set_name__
|
||||
|
||||
# check validity of Parameter entries
|
||||
for pname, pobj in accessibles.items():
|
||||
# XXX: create getters for the units of params ??
|
||||
|
||||
# wrap of reading/writing funcs
|
||||
if not isinstance(pobj, Parameter):
|
||||
# nothing to do for Commands
|
||||
continue
|
||||
|
||||
rfunc = getattr(cls, 'read_' + pname, None)
|
||||
|
||||
# create wrapper except when read function is already wrapped or auto generatoed
|
||||
if not getattr(rfunc, 'wrapped', False):
|
||||
|
||||
if rfunc:
|
||||
|
||||
@wraps(rfunc) # handles __wrapped__ and __doc__
|
||||
def new_rfunc(self, pname=pname, rfunc=rfunc):
|
||||
with self.accessLock:
|
||||
try:
|
||||
value = rfunc(self)
|
||||
self.log.debug("read_%s returned %r", pname, value)
|
||||
except Exception as e:
|
||||
self.log.debug("read_%s failed with %r", pname, e)
|
||||
self.announceUpdate(pname, None, e)
|
||||
raise
|
||||
if value is Done:
|
||||
return getattr(self, pname)
|
||||
setattr(self, pname, value) # important! trigger the setter
|
||||
return value
|
||||
|
||||
new_rfunc.poll = getattr(rfunc, 'poll', True)
|
||||
else:
|
||||
|
||||
def new_rfunc(self, pname=pname):
|
||||
return getattr(self, pname)
|
||||
|
||||
new_rfunc.poll = False
|
||||
new_rfunc.__doc__ = 'auto generated read method for ' + pname
|
||||
|
||||
new_rfunc.wrapped = True # indicate to subclasses that no more wrapping is needed
|
||||
setattr(cls, 'read_' + pname, new_rfunc)
|
||||
|
||||
wfunc = getattr(cls, 'write_' + pname, None)
|
||||
if not pobj.readonly or wfunc: # allow write_ method even when pobj is not readonly
|
||||
# create wrapper except when write function is already wrapped or auto generated
|
||||
if not getattr(wfunc, 'wrapped', False):
|
||||
|
||||
if wfunc:
|
||||
|
||||
@wraps(wfunc) # handles __wrapped__ and __doc__
|
||||
def new_wfunc(self, value, pname=pname, wfunc=wfunc):
|
||||
with self.accessLock:
|
||||
pobj = self.accessibles[pname]
|
||||
self.log.debug('validate %r for %r', value, pname)
|
||||
# we do not need to handle errors here, we do not
|
||||
# want to make a parameter invalid, when a write failed
|
||||
new_value = pobj.datatype(value)
|
||||
new_value = wfunc(self, new_value)
|
||||
self.log.debug('write_%s(%r) returned %r', pname, value, new_value)
|
||||
if new_value is Done:
|
||||
# setattr(self, pname, getattr(self, pname))
|
||||
return getattr(self, pname)
|
||||
setattr(self, pname, new_value) # important! trigger the setter
|
||||
return new_value
|
||||
else:
|
||||
|
||||
def new_wfunc(self, value, pname=pname):
|
||||
setattr(self, pname, value)
|
||||
return value
|
||||
|
||||
new_wfunc.__doc__ = 'auto generated write method for ' + pname
|
||||
|
||||
new_wfunc.wrapped = True # indicate to subclasses that no more wrapping is needed
|
||||
setattr(cls, 'write_' + pname, new_wfunc)
|
||||
|
||||
# check for programming errors
|
||||
for attrname in dir(cls):
|
||||
prefix, _, pname = attrname.partition('_')
|
||||
if not pname:
|
||||
continue
|
||||
if prefix == 'do':
|
||||
raise ProgrammingError('%r: old style command %r not supported anymore'
|
||||
% (cls.__name__, attrname))
|
||||
if prefix in ('read', 'write') and not getattr(getattr(cls, attrname), 'wrapped', False):
|
||||
raise ProgrammingError('%s.%s defined, but %r is no parameter'
|
||||
% (cls.__name__, attrname, pname))
|
||||
|
||||
res = {}
|
||||
# collect info about properties
|
||||
for pn, pv in cls.propertyDict.items():
|
||||
if pv.settable:
|
||||
res[pn] = pv
|
||||
# collect info about parameters and their properties
|
||||
for param, pobj in cls.accessibles.items():
|
||||
res[param] = {}
|
||||
for pn, pv in pobj.getProperties().items():
|
||||
if pv.settable:
|
||||
res[param][pn] = pv
|
||||
cls.configurables = res
|
||||
|
||||
|
||||
class Feature(HasAccessibles):
|
||||
"""all things belonging to a small, predefined functionality influencing the working of a module
|
||||
|
||||
a mixin with Feature as a direct base class is recognized as a SECoP feature
|
||||
and reported in the module property 'features'
|
||||
"""
|
||||
|
||||
|
||||
class PollInfo:
|
||||
def __init__(self, pollinterval, trigger_event):
|
||||
self.interval = pollinterval
|
||||
self.last_main = 0
|
||||
self.last_slow = 0
|
||||
self.last_error = {} # dict [<name of poll func>] of (None or str(last exception))
|
||||
self.polled_parameters = []
|
||||
self.fast_flag = False
|
||||
self.trigger_event = trigger_event
|
||||
|
||||
def trigger(self):
|
||||
"""trigger a recalculation of poll due times"""
|
||||
self.trigger_event.set()
|
||||
|
||||
def update_interval(self, pollinterval):
|
||||
if not self.fast_flag:
|
||||
self.interval = pollinterval
|
||||
self.trigger()
|
||||
|
||||
|
||||
class Module(HasAccessibles):
|
||||
"""basic module
|
||||
|
||||
all SECoP modules derive from this.
|
||||
|
||||
:param name: the modules name
|
||||
:param logger: a logger instance
|
||||
:param cfgdict: the dict from this modules section in the config file
|
||||
:param srv: the server instance
|
||||
|
||||
Notes:
|
||||
|
||||
- the programmer normally should not need to reimplement :meth:`__init__`
|
||||
- within modules, parameters should only be addressed as ``self.<pname>``,
|
||||
i.e. ``self.value``, ``self.target`` etc...
|
||||
|
||||
- these are accessing the cached version.
|
||||
- they can also be written to, generating an async update
|
||||
|
||||
- if you want to 'update from the hardware', call ``self.read_<pname>()`` instead
|
||||
|
||||
- the return value of this method will be used as the new cached value and
|
||||
be an async update sent automatically.
|
||||
|
||||
- if you want to 'update the hardware' call ``self.write_<pname>(<new value>)``.
|
||||
|
||||
- The return value of this method will also update the cache.
|
||||
|
||||
"""
|
||||
# static properties, definitions in derived classes should overwrite earlier ones.
|
||||
# note: properties don't change after startup and are usually filled
|
||||
# with data from a cfg file...
|
||||
# note: only the properties predefined here are allowed to be set in the cfg file
|
||||
export = Property('flag if this module is to be exported', BoolType(), default=True, export=False)
|
||||
group = Property('optional group the module belongs to', StringType(), default='', extname='group')
|
||||
description = Property('description of the module', TextType(), extname='description', mandatory=True)
|
||||
meaning = Property('optional meaning indicator', TupleOf(StringType(), IntRange(0, 50)),
|
||||
default=('', 0), extname='meaning')
|
||||
visibility = Property('optional visibility hint', EnumType('visibility', user=1, advanced=2, expert=3),
|
||||
default='user', extname='visibility')
|
||||
implementation = Property('internal name of the implementation class of the module', StringType(),
|
||||
extname='implementation')
|
||||
interface_classes = Property('offical highest interface-class of the module', ArrayOf(StringType()),
|
||||
extname='interface_classes')
|
||||
features = Property('list of features', ArrayOf(StringType()), extname='features')
|
||||
pollinterval = Property('poll interval for parameters handled by doPoll', FloatRange(0.1, 120), default=5)
|
||||
slowinterval = Property('poll interval for other parameters', FloatRange(0.1, 120), default=15)
|
||||
enablePoll = True
|
||||
|
||||
# properties, parameters and commands are auto-merged upon subclassing
|
||||
parameters = {}
|
||||
commands = {}
|
||||
|
||||
# reference to the dispatcher (used for sending async updates)
|
||||
DISPATCHER = None
|
||||
attachedModules = None
|
||||
pollInfo = None
|
||||
triggerPoll = None # trigger event for polls. used on io modules and modules without io
|
||||
|
||||
def __init__(self, name, logger, cfgdict, srv):
|
||||
# remember the dispatcher object (for the async callbacks)
|
||||
self.DISPATCHER = srv.dispatcher
|
||||
self.omit_unchanged_within = getattr(self.DISPATCHER, 'omit_unchanged_within', 0.1)
|
||||
self.log = logger
|
||||
self.name = name
|
||||
self.valueCallbacks = {}
|
||||
self.errorCallbacks = {}
|
||||
self.earlyInitDone = False
|
||||
self.initModuleDone = False
|
||||
self.startModuleDone = False
|
||||
self.remoteLogHandler = None
|
||||
self.accessLock = threading.RLock() # for read_* / write_* methods
|
||||
self.updateLock = threading.RLock() # for announceUpdate
|
||||
self.polledModules = [] # modules polled by thread started in self.startModules
|
||||
errors = []
|
||||
|
||||
# handle module properties
|
||||
# 1) make local copies of properties
|
||||
super().__init__()
|
||||
|
||||
# 2) check and apply properties specified in cfgdict as
|
||||
# '<propertyname> = <propertyvalue>'
|
||||
# pylint: disable=consider-using-dict-items
|
||||
for key in self.propertyDict:
|
||||
value = cfgdict.pop(key, None)
|
||||
if value is None:
|
||||
# legacy cfg: specified as '.<propertyname> = <propertyvalue>'
|
||||
value = cfgdict.pop('.' + key, None)
|
||||
if value is not None:
|
||||
try:
|
||||
self.setProperty(key, value)
|
||||
except BadValueError:
|
||||
errors.append('%s: value %r does not match %r!' %
|
||||
(key, value, self.propertyDict[key].datatype))
|
||||
|
||||
# 3) set automatic properties
|
||||
mycls = self.__class__
|
||||
myclassname = '%s.%s' % (mycls.__module__, mycls.__name__)
|
||||
self.implementation = myclassname
|
||||
# list of all 'secop' modules
|
||||
# self.interface_classes = [
|
||||
# b.__name__ for b in mycls.__mro__ if b.__module__.startswith('frappy.modules')]
|
||||
# list of only the 'highest' secop module class
|
||||
self.interface_classes = [
|
||||
b.__name__ for b in mycls.__mro__ if issubclass(Drivable, b)][0:1]
|
||||
|
||||
# handle Features
|
||||
self.features = [b.__name__ for b in mycls.__mro__ if Feature in b.__bases__]
|
||||
|
||||
# handle accessibles
|
||||
# 1) make local copies of parameter objects
|
||||
# they need to be individual per instance since we use them also
|
||||
# to cache the current value + qualifiers...
|
||||
accessibles = {}
|
||||
# conversion from exported names to internal attribute names
|
||||
accessiblename2attr = {}
|
||||
for aname, aobj in self.accessibles.items():
|
||||
# make a copy of the Parameter/Command object
|
||||
aobj = aobj.copy()
|
||||
if not self.export: # do not export parameters of a module not exported
|
||||
aobj.export = False
|
||||
if aobj.export:
|
||||
accessiblename2attr[aobj.export] = aname
|
||||
accessibles[aname] = aobj
|
||||
# do not re-use self.accessibles as this is the same for all instances
|
||||
self.accessibles = accessibles
|
||||
self.accessiblename2attr = accessiblename2attr
|
||||
# provide properties to 'filter' out the parameters/commands
|
||||
self.parameters = {k: v for k, v in accessibles.items() if isinstance(v, Parameter)}
|
||||
self.commands = {k: v for k, v in accessibles.items() if isinstance(v, Command)}
|
||||
|
||||
# 2) check and apply parameter_properties
|
||||
# specified as '<paramname>.<propertyname> = <propertyvalue>'
|
||||
# this may also be done on commands: e.g. 'stop.visibility = advanced'
|
||||
for k, v in list(cfgdict.items()): # keep list() as dict may change during iter
|
||||
if '.' in k[1:]:
|
||||
aname, propname = k.split('.', 1)
|
||||
propvalue = cfgdict.pop(k)
|
||||
aobj = self.accessibles.get(aname, None)
|
||||
if aobj:
|
||||
try:
|
||||
aobj.setProperty(propname, propvalue)
|
||||
except KeyError:
|
||||
errors.append("'%s.%s' does not exist" %
|
||||
(aname, propname))
|
||||
except BadValueError as e:
|
||||
errors.append('%s.%s: %s' %
|
||||
(aname, propname, str(e)))
|
||||
else:
|
||||
errors.append('%r not found' % aname)
|
||||
|
||||
# 3) check config for problems:
|
||||
# only accept remaining config items specified in parameters
|
||||
bad = [k for k in cfgdict if k not in self.parameters]
|
||||
if bad:
|
||||
errors.append(
|
||||
'%s does not exist (use one of %s)' %
|
||||
(', '.join(bad), ', '.join(list(self.parameters) +
|
||||
list(self.propertyDict))))
|
||||
|
||||
# 4) complain if a Parameter entry has no default value and
|
||||
# is not specified in cfgdict and deal with parameters to be written.
|
||||
self.writeDict = {} # values of parameters to be written
|
||||
for pname, pobj in self.parameters.items():
|
||||
self.valueCallbacks[pname] = []
|
||||
self.errorCallbacks[pname] = []
|
||||
|
||||
if not pobj.hasDatatype():
|
||||
errors.append('%s needs a datatype' % pname)
|
||||
continue
|
||||
|
||||
if pname in cfgdict:
|
||||
if pobj.initwrite is not False and hasattr(self, 'write_' + pname):
|
||||
# parameters given in cfgdict have to call write_<pname>
|
||||
try:
|
||||
pobj.value = pobj.datatype(cfgdict[pname])
|
||||
self.writeDict[pname] = pobj.value
|
||||
except BadValueError as e:
|
||||
errors.append('%s: %s' % (pname, e))
|
||||
else:
|
||||
if pobj.default is None:
|
||||
if pobj.needscfg:
|
||||
errors.append('%r has no default '
|
||||
'value and was not given in config!' % pname)
|
||||
# we do not want to call the setter for this parameter for now,
|
||||
# this should happen on the first read
|
||||
pobj.readerror = ConfigError('parameter %r not initialized' % pname)
|
||||
# above error will be triggered on activate after startup,
|
||||
# when not all hardware parameters are read because of startup timeout
|
||||
pobj.value = pobj.datatype(pobj.datatype.default)
|
||||
else:
|
||||
try:
|
||||
value = pobj.datatype(pobj.default)
|
||||
except BadValueError as e:
|
||||
# this should not happen, as the default is already checked in Parameter
|
||||
raise ProgrammingError('bad default for %s:%s: %s' % (name, pname, e)) from None
|
||||
if pobj.initwrite and hasattr(self, 'write_' + pname):
|
||||
# we will need to call write_<pname>
|
||||
pobj.value = value
|
||||
self.writeDict[pname] = value
|
||||
else:
|
||||
cfgdict[pname] = value
|
||||
|
||||
# 5) 'apply' config:
|
||||
# pass values through the datatypes and store as attributes
|
||||
for k, v in list(cfgdict.items()):
|
||||
try:
|
||||
# this checks also for the proper datatype
|
||||
# note: this will NOT call write_* methods!
|
||||
if k in self.parameters or k in self.propertyDict:
|
||||
setattr(self, k, v)
|
||||
cfgdict.pop(k)
|
||||
except (ValueError, TypeError) as e:
|
||||
# self.log.exception(formatExtendedStack())
|
||||
errors.append('parameter %s: %s' % (k, e))
|
||||
|
||||
# ensure consistency
|
||||
for aobj in self.accessibles.values():
|
||||
aobj.finish()
|
||||
|
||||
# Modify units AFTER applying the cfgdict
|
||||
mainvalue = self.parameters.get('value')
|
||||
if mainvalue:
|
||||
mainunit = mainvalue.datatype.unit
|
||||
if mainunit:
|
||||
self.applyMainUnit(mainunit)
|
||||
|
||||
# 6) check complete configuration of * properties
|
||||
if not errors:
|
||||
try:
|
||||
self.checkProperties()
|
||||
except ConfigError as e:
|
||||
errors.append(str(e))
|
||||
for aname, aobj in self.accessibles.items():
|
||||
try:
|
||||
aobj.checkProperties()
|
||||
except (ConfigError, ProgrammingError) as e:
|
||||
errors.append('%s: %s' % (aname, e))
|
||||
if errors:
|
||||
raise ConfigError(errors)
|
||||
|
||||
# helper cfg-editor
|
||||
def __iter__(self):
|
||||
return self.accessibles.__iter__()
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self.accessibles.__getitem__(item)
|
||||
|
||||
def applyMainUnit(self, mainunit):
|
||||
"""replace $ in units of parameters by mainunit"""
|
||||
for pobj in self.parameters.values():
|
||||
pobj.datatype.set_main_unit(mainunit)
|
||||
|
||||
def announceUpdate(self, pname, value=None, err=None, timestamp=None):
|
||||
"""announce a changed value or readerror"""
|
||||
|
||||
with self.updateLock:
|
||||
# TODO: remove readerror 'property' and replace value with exception
|
||||
pobj = self.parameters[pname]
|
||||
timestamp = timestamp or time.time()
|
||||
try:
|
||||
value = pobj.datatype(value)
|
||||
changed = pobj.value != value
|
||||
# store the value even in case of error
|
||||
pobj.value = value
|
||||
except Exception as e:
|
||||
if isinstance(e, DiscouragedConversion):
|
||||
if DiscouragedConversion.log_message:
|
||||
self.log.error(str(e))
|
||||
self.log.error('you may disable this behaviour by running the server with --relaxed')
|
||||
DiscouragedConversion.log_message = False
|
||||
if not err: # do not overwrite given error
|
||||
err = e
|
||||
if err:
|
||||
err = secop_error(err)
|
||||
if str(err) == str(pobj.readerror):
|
||||
return # no updates for repeated errors
|
||||
elif not changed and timestamp < (pobj.timestamp or 0) + self.omit_unchanged_within:
|
||||
# no change within short time -> omit
|
||||
return
|
||||
pobj.timestamp = timestamp or time.time()
|
||||
pobj.readerror = err
|
||||
if pobj.export:
|
||||
self.DISPATCHER.announce_update(self.name, pname, pobj)
|
||||
if err:
|
||||
callbacks = self.errorCallbacks
|
||||
arg = err
|
||||
else:
|
||||
callbacks = self.valueCallbacks
|
||||
arg = value
|
||||
cblist = callbacks[pname]
|
||||
for cb in cblist:
|
||||
try:
|
||||
cb(arg)
|
||||
except Exception:
|
||||
# print(formatExtendedTraceback())
|
||||
pass
|
||||
|
||||
def registerCallbacks(self, modobj, autoupdate=()):
|
||||
"""register callbacks to another module <modobj>
|
||||
|
||||
- whenever a self.<param> changes:
|
||||
<modobj>.update_<param> is called with the new value as argument.
|
||||
If this method raises an exception, <modobj>.<param> gets into an error state.
|
||||
If the method does not exist and <param> is in autoupdate,
|
||||
<modobj>.<param> is updated to self.<param>
|
||||
- whenever <self>.<param> gets into an error state:
|
||||
<modobj>.error_update_<param> is called with the exception as argument.
|
||||
If this method raises an error, <modobj>.<param> gets into an error state.
|
||||
If this method does not exist, and <param> is in autoupdate,
|
||||
<modobj>.<param> gets into the same error state as self.<param>
|
||||
"""
|
||||
for pname in self.parameters:
|
||||
errfunc = getattr(modobj, 'error_update_' + pname, None)
|
||||
if errfunc:
|
||||
def errcb(err, p=pname, efunc=errfunc):
|
||||
try:
|
||||
efunc(err)
|
||||
except Exception as e:
|
||||
modobj.announceUpdate(p, err=e)
|
||||
self.errorCallbacks[pname].append(errcb)
|
||||
else:
|
||||
def errcb(err, p=pname):
|
||||
modobj.announceUpdate(p, err=err)
|
||||
if pname in autoupdate:
|
||||
self.errorCallbacks[pname].append(errcb)
|
||||
|
||||
updfunc = getattr(modobj, 'update_' + pname, None)
|
||||
if updfunc:
|
||||
def cb(value, ufunc=updfunc, efunc=errcb):
|
||||
try:
|
||||
ufunc(value)
|
||||
except Exception as e:
|
||||
efunc(e)
|
||||
self.valueCallbacks[pname].append(cb)
|
||||
elif pname in autoupdate:
|
||||
def cb(value, p=pname):
|
||||
modobj.announceUpdate(p, value)
|
||||
self.valueCallbacks[pname].append(cb)
|
||||
|
||||
def isBusy(self, status=None):
|
||||
"""helper function for treating substates of BUSY correctly"""
|
||||
# defined even for non drivable (used for dynamic polling)
|
||||
return False
|
||||
|
||||
def earlyInit(self):
|
||||
"""initialise module with stuff to be done before all modules are created"""
|
||||
self.earlyInitDone = True
|
||||
|
||||
def initModule(self):
|
||||
"""initialise module with stuff to be done after all modules are created"""
|
||||
self.initModuleDone = True
|
||||
if self.enablePoll or self.writeDict:
|
||||
# enablePoll == False: we still need the poll thread for writing values from writeDict
|
||||
if hasattr(self, 'io'):
|
||||
self.io.polledModules.append(self)
|
||||
else:
|
||||
self.triggerPoll = threading.Event()
|
||||
self.polledModules.append(self)
|
||||
|
||||
def startModule(self, start_events):
|
||||
"""runs after init of all modules
|
||||
|
||||
when a thread is started, a trigger function may signal that it
|
||||
has finished its initial work
|
||||
start_events.get_trigger(<timeout>) creates such a trigger and
|
||||
registers it in the server for waiting
|
||||
<timeout> defaults to 30 seconds
|
||||
"""
|
||||
if self.polledModules:
|
||||
mkthread(self.__pollThread, self.polledModules, start_events.get_trigger())
|
||||
self.startModuleDone = True
|
||||
|
||||
def doPoll(self):
|
||||
"""polls important parameters like value and status
|
||||
|
||||
all other parameters are polled automatically
|
||||
"""
|
||||
|
||||
def setFastPoll(self, flag, fast_interval=0.25):
|
||||
"""change poll interval
|
||||
|
||||
:param flag: enable/disable fast poll mode
|
||||
:param fast_interval: fast poll interval
|
||||
"""
|
||||
if self.pollInfo:
|
||||
self.pollInfo.fast_flag = flag
|
||||
self.pollInfo.interval = fast_interval if flag else self.pollinterval
|
||||
self.pollInfo.trigger()
|
||||
|
||||
def callPollFunc(self, rfunc, raise_com_failed=False):
|
||||
"""call read method with proper error handling"""
|
||||
try:
|
||||
rfunc()
|
||||
self.pollInfo.last_error[rfunc.__name__] = None
|
||||
except Exception as e:
|
||||
name = rfunc.__name__
|
||||
if str(e) != self.pollInfo.last_error.get(name):
|
||||
self.pollInfo.last_error[name] = str(e)
|
||||
if isinstance(e, SECoPError):
|
||||
if e.silent:
|
||||
self.log.debug('%s: %s', name, str(e))
|
||||
else:
|
||||
self.log.error('%s: %s', name, str(e))
|
||||
else:
|
||||
# uncatched error: this is more serious
|
||||
self.log.error('%s: %s', name, formatException())
|
||||
if raise_com_failed and isinstance(e, CommunicationFailedError):
|
||||
raise
|
||||
|
||||
def __pollThread(self, modules, started_callback):
|
||||
"""poll thread body
|
||||
|
||||
:param modules: list of modules to be handled by this thread
|
||||
:param started_callback: to be called after all polls are done once
|
||||
|
||||
before polling, parameters which need hardware initialisation are written
|
||||
"""
|
||||
for mobj in modules:
|
||||
mobj.writeInitParams()
|
||||
modules = [m for m in modules if m.enablePoll]
|
||||
if not modules: # no polls needed - exit thread
|
||||
started_callback()
|
||||
return
|
||||
if hasattr(self, 'registerReconnectCallback'):
|
||||
# self is a communicator supporting reconnections
|
||||
def trigger_all(trg=self.triggerPoll, polled_modules=modules):
|
||||
for m in polled_modules:
|
||||
m.pollInfo.last_main = 0
|
||||
m.pollInfo.last_slow = 0
|
||||
trg.set()
|
||||
self.registerReconnectCallback('trigger_polls', trigger_all)
|
||||
|
||||
# collect all read functions
|
||||
for mobj in modules:
|
||||
pinfo = mobj.pollInfo = PollInfo(mobj.pollinterval, self.triggerPoll)
|
||||
# trigger a poll interval change when self.pollinterval changes.
|
||||
if 'pollinterval' in mobj.valueCallbacks:
|
||||
mobj.valueCallbacks['pollinterval'].append(pinfo.update_interval)
|
||||
|
||||
for pname, pobj in mobj.parameters.items():
|
||||
rfunc = getattr(mobj, 'read_' + pname)
|
||||
if rfunc.poll:
|
||||
pinfo.polled_parameters.append((mobj, rfunc, pobj))
|
||||
# call all read functions a first time
|
||||
try:
|
||||
for m in modules:
|
||||
for mobj, rfunc, _ in m.pollInfo.polled_parameters:
|
||||
mobj.callPollFunc(rfunc, raise_com_failed=True)
|
||||
except CommunicationFailedError as e:
|
||||
# when communication failed, probably all parameters and may be more modules are affected.
|
||||
# as this would take a lot of time (summed up timeouts), we do not continue
|
||||
# trying and let the server accept connections, further polls might success later
|
||||
self.log.error('communication failure on startup: %s', e)
|
||||
started_callback()
|
||||
to_poll = ()
|
||||
while True:
|
||||
now = time.time()
|
||||
wait_time = 999
|
||||
for mobj in modules:
|
||||
pinfo = mobj.pollInfo
|
||||
wait_time = min(pinfo.last_main + pinfo.interval - now, wait_time,
|
||||
pinfo.last_slow + mobj.slowinterval - now)
|
||||
if wait_time > 0:
|
||||
self.triggerPoll.wait(wait_time)
|
||||
self.triggerPoll.clear()
|
||||
continue
|
||||
# call doPoll of all modules where due
|
||||
for mobj in modules:
|
||||
pinfo = mobj.pollInfo
|
||||
if now > pinfo.last_main + pinfo.interval:
|
||||
pinfo.last_main = (now // pinfo.interval) * pinfo.interval
|
||||
mobj.callPollFunc(mobj.doPoll)
|
||||
now = time.time()
|
||||
# find ONE due slow poll and call it
|
||||
loop = True
|
||||
while loop: # loops max. 2 times, when to_poll is at end
|
||||
for mobj, rfunc, pobj in to_poll:
|
||||
if now > pobj.timestamp + mobj.slowinterval * 0.5:
|
||||
mobj.callPollFunc(rfunc)
|
||||
loop = False # one poll done
|
||||
break
|
||||
else:
|
||||
to_poll = []
|
||||
# collect due slow polls
|
||||
for mobj in modules:
|
||||
pinfo = mobj.pollInfo
|
||||
if now > pinfo.last_slow + mobj.slowinterval:
|
||||
to_poll.extend(pinfo.polled_parameters)
|
||||
pinfo.last_slow = (now // mobj.slowinterval) * mobj.slowinterval
|
||||
if to_poll:
|
||||
to_poll = iter(to_poll)
|
||||
else:
|
||||
loop = False # no slow polls ready
|
||||
|
||||
def writeInitParams(self, started_callback=None):
|
||||
"""write values for parameters with configured values
|
||||
|
||||
this must be called at the beginning of the poller thread
|
||||
with proper error handling
|
||||
"""
|
||||
for pname in list(self.writeDict):
|
||||
value = self.writeDict.pop(pname, Done)
|
||||
# in the mean time, a poller or handler might already have done it
|
||||
if value is not Done:
|
||||
wfunc = getattr(self, 'write_' + pname, None)
|
||||
if wfunc is None:
|
||||
setattr(self, pname, value)
|
||||
else:
|
||||
try:
|
||||
self.log.debug('initialize parameter %s', pname)
|
||||
wfunc(value)
|
||||
except SECoPError as e:
|
||||
if e.silent:
|
||||
self.log.debug('%s: %s', pname, str(e))
|
||||
else:
|
||||
self.log.error('%s: %s', pname, str(e))
|
||||
except Exception:
|
||||
self.log.error(formatException())
|
||||
if started_callback:
|
||||
started_callback()
|
||||
|
||||
def setRemoteLogging(self, conn, level):
|
||||
if self.remoteLogHandler is None:
|
||||
for handler in self.log.handlers:
|
||||
if isinstance(handler, RemoteLogHandler):
|
||||
self.remoteLogHandler = handler
|
||||
break
|
||||
else:
|
||||
raise ValueError('remote handler not found')
|
||||
self.remoteLogHandler.set_conn_level(self, conn, level)
|
||||
|
||||
|
||||
class Readable(Module):
|
||||
"""basic readable module"""
|
||||
# pylint: disable=invalid-name
|
||||
Status = Enum('Status',
|
||||
IDLE=100,
|
||||
WARN=200,
|
||||
UNSTABLE=270,
|
||||
ERROR=400,
|
||||
DISABLED=0,
|
||||
UNKNOWN=401,
|
||||
) #: status codes
|
||||
|
||||
value = Parameter('current value of the module', FloatRange())
|
||||
status = Parameter('current status of the module', TupleOf(EnumType(Status), StringType()),
|
||||
default=(Status.IDLE, ''))
|
||||
pollinterval = Parameter('default poll interval', FloatRange(0.1, 120),
|
||||
default=5, readonly=False, export=True)
|
||||
|
||||
def doPoll(self):
|
||||
self.read_value()
|
||||
self.read_status()
|
||||
|
||||
|
||||
class Writable(Readable):
|
||||
"""basic writable module"""
|
||||
disable_value_range_check = Property('disable value range check', BoolType(), default=False)
|
||||
target = Parameter('target value of the module',
|
||||
default=0, readonly=False, datatype=FloatRange(unit='$'))
|
||||
|
||||
def __init__(self, name, logger, cfgdict, srv):
|
||||
super().__init__(name, logger, cfgdict, srv)
|
||||
value_dt = self.parameters['value'].datatype
|
||||
target_dt = self.parameters['target'].datatype
|
||||
try:
|
||||
# this handles also the cases where the limits on the value are more
|
||||
# restrictive than on the target
|
||||
target_dt.compatible(value_dt)
|
||||
except Exception:
|
||||
if type(value_dt) == type(target_dt):
|
||||
raise ConfigError('the target range extends beyond the value range') from None
|
||||
raise ProgrammingError('the datatypes of target and value are not compatible') from None
|
||||
if isinstance(value_dt, FloatRange):
|
||||
if (not self.disable_value_range_check and not generalConfig.disable_value_range_check
|
||||
and value_dt.problematic_range(target_dt)):
|
||||
self.log.error('the value range must be bigger than the target range!')
|
||||
self.log.error('you may disable this error message by running the server with --relaxed')
|
||||
self.log.error('or by setting the disable_value_range_check property of the module to True')
|
||||
raise ConfigError('the value range must be bigger than the target range')
|
||||
|
||||
|
||||
class Drivable(Writable):
|
||||
"""basic drivable module"""
|
||||
|
||||
Status = Enum(Readable.Status, BUSY=300) #: status codes
|
||||
|
||||
status = Parameter(datatype=StatusType(Status)) # override Readable.status
|
||||
|
||||
def isBusy(self, status=None):
|
||||
"""check for busy, treating substates correctly
|
||||
|
||||
returns True when busy (also when finalizing)
|
||||
"""
|
||||
return 300 <= (status or self.status)[0] < 400
|
||||
|
||||
def isDriving(self, status=None):
|
||||
"""check for driving, treating status substates correctly
|
||||
|
||||
returns True when busy, but not finalizing
|
||||
"""
|
||||
return 300 <= (status or self.status)[0] < 390
|
||||
|
||||
@Command(None, result=None)
|
||||
def stop(self):
|
||||
"""cease driving, go to IDLE state"""
|
||||
|
||||
|
||||
class Communicator(HasComlog, Module):
|
||||
"""basic abstract communication module"""
|
||||
|
||||
@Command(StringType(), result=StringType())
|
||||
def communicate(self, command):
|
||||
"""communicate command
|
||||
|
||||
:param command: the command to be sent
|
||||
:return: the reply
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class Attached(Property):
|
||||
"""a special property, defining an attached module
|
||||
|
||||
assign a module name to this property in the cfg file,
|
||||
and the server will create an attribute with this module
|
||||
"""
|
||||
def __init__(self, basecls=Module, description='attached module', mandatory=True):
|
||||
self.basecls = basecls
|
||||
super().__init__(description, StringType(), mandatory=mandatory)
|
||||
|
||||
def __get__(self, obj, owner):
|
||||
if obj is None:
|
||||
return self
|
||||
if obj.attachedModules is None:
|
||||
# return the name of the module (called from Server on startup)
|
||||
return super().__get__(obj, owner)
|
||||
# return the module (called after startup)
|
||||
return obj.attachedModules.get(self.name) # return None if not given
|
||||
504
frappy/params.py
Normal file
@@ -0,0 +1,504 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define classes for Parameters/Commands and Overriding them"""
|
||||
|
||||
|
||||
import inspect
|
||||
|
||||
from frappy.datatypes import BoolType, CommandType, DataType, \
|
||||
DataTypeType, EnumType, NoneOr, OrType, \
|
||||
StringType, StructOf, TextType, TupleOf, ValueType
|
||||
from frappy.errors import BadValueError, ProgrammingError
|
||||
from frappy.properties import HasProperties, Property
|
||||
from frappy.lib import generalConfig
|
||||
|
||||
generalConfig.set_default('tolerate_poll_property', False)
|
||||
|
||||
|
||||
class Accessible(HasProperties):
|
||||
"""base class for Parameter and Command
|
||||
|
||||
Inheritance mechanism:
|
||||
|
||||
param.propertyValues contains the properties, which will be used when the
|
||||
owner class will be instantiated
|
||||
|
||||
param.ownProperties contains the properties to be used for inheritance
|
||||
"""
|
||||
|
||||
ownProperties = None
|
||||
|
||||
def init(self, kwds):
|
||||
# do not use self.propertyValues.update here, as no invalid values should be
|
||||
# assigned to properties, even not before checkProperties
|
||||
for k, v in kwds.items():
|
||||
self.setProperty(k, v)
|
||||
|
||||
def as_dict(self):
|
||||
return self.propertyValues
|
||||
|
||||
def override(self, value):
|
||||
"""override with a bare value"""
|
||||
raise NotImplementedError
|
||||
|
||||
def copy(self):
|
||||
"""return a (deep) copy of ourselfs"""
|
||||
raise NotImplementedError
|
||||
|
||||
def updateProperties(self, merged_properties):
|
||||
"""update merged_properties with our own properties"""
|
||||
raise NotImplementedError
|
||||
|
||||
def merge(self, merged_properties):
|
||||
"""merge with inherited properties
|
||||
|
||||
:param merged_properties: dict of properties to be updated
|
||||
note: merged_properties may be modified
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def finish(self):
|
||||
"""ensure consistency"""
|
||||
raise NotImplementedError
|
||||
|
||||
def for_export(self):
|
||||
"""prepare for serialisation"""
|
||||
raise NotImplementedError
|
||||
|
||||
def hasDatatype(self):
|
||||
return 'datatype' in self.propertyValues
|
||||
|
||||
def __repr__(self):
|
||||
props = []
|
||||
for k, v in sorted(self.propertyValues.items()):
|
||||
props.append('%s=%r' % (k, v))
|
||||
return '%s(%s)' % (self.__class__.__name__, ', '.join(props))
|
||||
|
||||
|
||||
class Parameter(Accessible):
|
||||
"""defines a parameter
|
||||
|
||||
:param description: description
|
||||
:param datatype: the datatype
|
||||
:param inherit: whether properties not given should be inherited
|
||||
:param kwds: optional properties
|
||||
"""
|
||||
# storage for Parameter settings + value + qualifiers
|
||||
|
||||
description = Property(
|
||||
'mandatory description of the parameter', TextType(),
|
||||
extname='description', mandatory=True, export='always')
|
||||
datatype = Property(
|
||||
'datatype of the Parameter (SECoP datainfo)', DataTypeType(),
|
||||
extname='datainfo', mandatory=True, export='always', default=ValueType())
|
||||
readonly = Property(
|
||||
'not changeable via SECoP (default True)', BoolType(),
|
||||
extname='readonly', default=True, export='always')
|
||||
group = Property(
|
||||
'optional parameter group this parameter belongs to', StringType(),
|
||||
extname='group', default='')
|
||||
visibility = Property(
|
||||
'optional visibility hint', EnumType('visibility', user=1, advanced=2, expert=3),
|
||||
extname='visibility', default=1)
|
||||
constant = Property(
|
||||
'optional constant value for constant parameters', ValueType(),
|
||||
extname='constant', default=None)
|
||||
default = Property(
|
||||
'''[internal] default (startup) value of this parameter
|
||||
|
||||
if it can not be read from the hardware''', ValueType(),
|
||||
export=False, default=None)
|
||||
export = Property(
|
||||
'''[internal] export settings
|
||||
|
||||
* False: not accessible via SECoP.
|
||||
* True: exported, name automatic.
|
||||
* a string: exported with custom name''', OrType(BoolType(), StringType()),
|
||||
export=False, default=True)
|
||||
needscfg = Property(
|
||||
'[internal] needs value in config', NoneOr(BoolType()),
|
||||
export=False, default=False)
|
||||
optional = Property(
|
||||
'[internal] is this parameter optional?', BoolType(),
|
||||
export=False, settable=False, default=False)
|
||||
initwrite = Property(
|
||||
'''[internal] write this parameter on initialization
|
||||
|
||||
default None: write if given in config''', NoneOr(BoolType()),
|
||||
export=False, default=None, settable=False)
|
||||
|
||||
# used on the instance copy only
|
||||
value = None
|
||||
timestamp = 0
|
||||
readerror = None
|
||||
|
||||
def __init__(self, description=None, datatype=None, inherit=True, **kwds):
|
||||
super().__init__()
|
||||
if 'poll' in kwds and generalConfig.tolerate_poll_property:
|
||||
kwds.pop('poll')
|
||||
if datatype is None:
|
||||
# collect datatype properties. these are not applied, as we have no datatype
|
||||
self.ownProperties = {k: kwds.pop(k) for k in list(kwds) if k not in self.propertyDict}
|
||||
else:
|
||||
self.ownProperties = {}
|
||||
if not isinstance(datatype, DataType):
|
||||
if isinstance(datatype, type) and issubclass(datatype, DataType):
|
||||
# goodie: make an instance from a class (forgotten ()???)
|
||||
datatype = datatype()
|
||||
else:
|
||||
raise ProgrammingError(
|
||||
'datatype MUST be derived from class DataType!')
|
||||
self.datatype = datatype
|
||||
if 'default' in kwds:
|
||||
self.default = datatype(kwds['default'])
|
||||
|
||||
if description is not None:
|
||||
kwds['description'] = inspect.cleandoc(description)
|
||||
|
||||
self.init(kwds)
|
||||
|
||||
if inherit:
|
||||
self.ownProperties.update(self.propertyValues)
|
||||
else:
|
||||
self.ownProperties = {k: getattr(self, k) for k in self.propertyDict}
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if instance is None:
|
||||
return self
|
||||
return instance.parameters[self.name].value
|
||||
|
||||
def __set__(self, obj, value):
|
||||
obj.announceUpdate(self.name, value)
|
||||
|
||||
def __set_name__(self, owner, name):
|
||||
self.name = name
|
||||
if isinstance(self.datatype, EnumType):
|
||||
self.datatype.set_name(name)
|
||||
|
||||
if self.export is True:
|
||||
predefined_cls = PREDEFINED_ACCESSIBLES.get(self.name, None)
|
||||
if predefined_cls is Parameter:
|
||||
self.export = self.name
|
||||
elif predefined_cls is None:
|
||||
self.export = '_' + self.name
|
||||
else:
|
||||
raise ProgrammingError('can not use %r as name of a Parameter' % self.name)
|
||||
if 'export' in self.ownProperties:
|
||||
# avoid export=True overrides export=<name>
|
||||
self.ownProperties['export'] = self.export
|
||||
|
||||
def copy(self):
|
||||
"""return a (deep) copy of ourselfs"""
|
||||
res = type(self)()
|
||||
res.name = self.name
|
||||
res.init(self.propertyValues)
|
||||
if 'datatype' in self.propertyValues:
|
||||
res.datatype = res.datatype.copy()
|
||||
return res
|
||||
|
||||
def updateProperties(self, merged_properties):
|
||||
"""update merged_properties with our own properties"""
|
||||
datatype = self.ownProperties.get('datatype')
|
||||
if datatype is not None:
|
||||
# clear datatype properties, as they are overriden by datatype
|
||||
for key in list(merged_properties):
|
||||
if key not in self.propertyDict:
|
||||
merged_properties.pop(key)
|
||||
merged_properties.update(self.ownProperties)
|
||||
|
||||
def override(self, value):
|
||||
"""override default"""
|
||||
self.default = self.datatype(value)
|
||||
|
||||
def merge(self, merged_properties):
|
||||
"""merge with inherited properties
|
||||
|
||||
:param merged_properties: dict of properties to be updated
|
||||
note: merged_properties may be modified
|
||||
"""
|
||||
datatype = merged_properties.pop('datatype', None)
|
||||
if datatype is not None:
|
||||
self.datatype = datatype.copy()
|
||||
self.init(merged_properties)
|
||||
self.finish()
|
||||
|
||||
def finish(self):
|
||||
"""ensure consistency"""
|
||||
|
||||
if self.constant is not None:
|
||||
constant = self.datatype(self.constant)
|
||||
# The value of the `constant` property should be the
|
||||
# serialised version of the constant, or unset
|
||||
self.constant = self.datatype.export_value(constant)
|
||||
self.readonly = True
|
||||
if 'default' in self.propertyValues:
|
||||
# fixes in case datatype has changed
|
||||
try:
|
||||
self.default = self.datatype(self.default)
|
||||
except BadValueError:
|
||||
# clear default, if it does not match datatype
|
||||
self.propertyValues.pop('default')
|
||||
|
||||
def export_value(self):
|
||||
return self.datatype.export_value(self.value)
|
||||
|
||||
def for_export(self):
|
||||
return dict(self.exportProperties(), readonly=self.readonly)
|
||||
|
||||
def getProperties(self):
|
||||
"""get also properties of datatype"""
|
||||
super_prop = super().getProperties().copy()
|
||||
if self.datatype:
|
||||
super_prop.update(self.datatype.getProperties())
|
||||
return super_prop
|
||||
|
||||
def setProperty(self, key, value):
|
||||
"""set also properties of datatype"""
|
||||
try:
|
||||
if key in self.propertyDict:
|
||||
super().setProperty(key, value)
|
||||
else:
|
||||
try:
|
||||
self.datatype.setProperty(key, value)
|
||||
except KeyError:
|
||||
raise ProgrammingError('cannot set %s on parameter with datatype %s'
|
||||
% (key, type(self.datatype).__name__)) from None
|
||||
except ValueError as e:
|
||||
raise ProgrammingError('property %s: %s' % (key, str(e))) from None
|
||||
|
||||
def checkProperties(self):
|
||||
super().checkProperties()
|
||||
self.datatype.checkProperties()
|
||||
|
||||
|
||||
class Command(Accessible):
|
||||
"""decorator to turn a method into a command
|
||||
|
||||
:param argument: the datatype of the argument or None
|
||||
:param result: the datatype of the result or None
|
||||
:param inherit: whether properties not given should be inherited
|
||||
:param kwds: optional properties
|
||||
"""
|
||||
|
||||
description = Property(
|
||||
'description of the Command', TextType(),
|
||||
extname='description', export='always', mandatory=True)
|
||||
group = Property(
|
||||
'optional command group of the command.', StringType(),
|
||||
extname='group', export=True, default='')
|
||||
visibility = Property(
|
||||
'optional visibility hint', EnumType('visibility', user=1, advanced=2, expert=3),
|
||||
extname='visibility', export=True, default=1)
|
||||
export = Property(
|
||||
'''[internal] export settings
|
||||
|
||||
* False: not accessible via SECoP.
|
||||
* True: exported, name automatic.
|
||||
* a string: exported with custom name''', OrType(BoolType(), StringType()),
|
||||
export=False, default=True)
|
||||
optional = Property(
|
||||
'[internal] is the command optional to implement? (vs. mandatory)', BoolType(),
|
||||
export=False, default=False, settable=False)
|
||||
datatype = Property(
|
||||
"datatype of the command, auto generated from 'argument' and 'result'",
|
||||
DataTypeType(), extname='datainfo', export='always')
|
||||
argument = Property(
|
||||
'datatype of the argument to the command, or None', NoneOr(DataTypeType()),
|
||||
export=False, mandatory=True)
|
||||
result = Property(
|
||||
'datatype of the result from the command, or None', NoneOr(DataTypeType()),
|
||||
export=False, mandatory=True)
|
||||
|
||||
func = None
|
||||
|
||||
def __init__(self, argument=False, *, result=None, inherit=True, **kwds):
|
||||
super().__init__()
|
||||
if 'datatype' in kwds:
|
||||
# self.init will complain about invalid keywords except 'datatype', as this is a property
|
||||
raise ProgrammingError("Command() got an invalid keyword 'datatype'")
|
||||
self.init(kwds)
|
||||
if result or kwds or isinstance(argument, DataType) or not callable(argument):
|
||||
# normal case
|
||||
if argument is False and result:
|
||||
argument = None
|
||||
if argument is not False:
|
||||
if isinstance(argument, (tuple, list)):
|
||||
# goodie: treat as TupleOf
|
||||
argument = TupleOf(*argument)
|
||||
self.argument = argument
|
||||
self.result = result
|
||||
else:
|
||||
# goodie: allow @Command instead of @Command()
|
||||
self.func = argument # this is the wrapped method!
|
||||
if argument.__doc__:
|
||||
self.description = inspect.cleandoc(argument.__doc__)
|
||||
self.name = self.func.__name__ # this is probably not needed
|
||||
self._inherit = inherit # save for __set_name__
|
||||
self.ownProperties = self.propertyValues.copy()
|
||||
|
||||
def __set_name__(self, owner, name):
|
||||
self.name = name
|
||||
if self.func is None:
|
||||
raise ProgrammingError('Command %s.%s must be used as a method decorator' %
|
||||
(owner.__name__, name))
|
||||
|
||||
self.datatype = CommandType(self.argument, self.result)
|
||||
if self.export is True:
|
||||
predefined_cls = PREDEFINED_ACCESSIBLES.get(name, None)
|
||||
if predefined_cls is Command:
|
||||
self.export = name
|
||||
elif predefined_cls is None:
|
||||
self.export = '_' + name
|
||||
else:
|
||||
raise ProgrammingError('can not use %r as name of a Command' % name) from None
|
||||
if 'export' in self.ownProperties:
|
||||
# avoid export=True overrides export=<name>
|
||||
self.ownProperties['export'] = self.export
|
||||
if not self._inherit:
|
||||
for key, pobj in self.properties.items():
|
||||
if key not in self.propertyValues:
|
||||
self.propertyValues[key] = pobj.default
|
||||
|
||||
def __get__(self, obj, owner=None):
|
||||
if obj is None:
|
||||
return self
|
||||
if not self.func:
|
||||
raise ProgrammingError('Command %s not properly configured' % self.name) from None
|
||||
return self.func.__get__(obj, owner)
|
||||
|
||||
def __call__(self, func):
|
||||
"""called when used as decorator"""
|
||||
if 'description' not in self.propertyValues and func.__doc__:
|
||||
self.description = inspect.cleandoc(func.__doc__)
|
||||
self.ownProperties['description'] = self.description
|
||||
self.func = func
|
||||
return self
|
||||
|
||||
def copy(self):
|
||||
"""return a (deep) copy of ourselfs"""
|
||||
res = type(self)()
|
||||
res.name = self.name
|
||||
res.func = self.func
|
||||
res.init(self.propertyValues)
|
||||
if res.argument:
|
||||
res.argument = res.argument.copy()
|
||||
if res.result:
|
||||
res.result = res.result.copy()
|
||||
self.finish()
|
||||
return res
|
||||
|
||||
def updateProperties(self, merged_properties):
|
||||
"""update merged_properties with our own properties"""
|
||||
merged_properties.update(self.ownProperties)
|
||||
|
||||
def override(self, value):
|
||||
"""override method
|
||||
|
||||
this is needed when the @Command is missing on a method overriding a command"""
|
||||
if not callable(value):
|
||||
raise ProgrammingError('%s = %r is overriding a Command' % (self.name, value))
|
||||
self.func = value
|
||||
if value.__doc__:
|
||||
self.description = inspect.cleandoc(value.__doc__)
|
||||
|
||||
def merge(self, merged_properties):
|
||||
"""merge with inherited properties
|
||||
|
||||
:param merged_properties: dict of properties to be updated
|
||||
"""
|
||||
self.init(merged_properties)
|
||||
self.finish()
|
||||
|
||||
def finish(self):
|
||||
"""ensure consistency"""
|
||||
self.datatype = CommandType(self.argument, self.result)
|
||||
|
||||
def setProperty(self, key, value):
|
||||
"""special treatment of datatype"""
|
||||
try:
|
||||
if key == 'datatype':
|
||||
command = DataTypeType()(value)
|
||||
super().setProperty('argument', command.argument)
|
||||
super().setProperty('result', command.result)
|
||||
super().setProperty(key, value)
|
||||
except ValueError as e:
|
||||
raise ProgrammingError('property %s: %s' % (key, str(e))) from None
|
||||
|
||||
def do(self, module_obj, argument):
|
||||
"""perform function call
|
||||
|
||||
:param module_obj: the module on which the command is to be executed
|
||||
:param argument: the argument from the do command
|
||||
:returns: the return value converted to the result type
|
||||
|
||||
- when the argument type is TupleOf, the function is called with multiple arguments
|
||||
- when the argument type is StructOf, the function is called with keyworded arguments
|
||||
- the validity of the argument/s is/are checked
|
||||
"""
|
||||
# pylint: disable=unnecessary-dunder-call
|
||||
func = self.__get__(module_obj)
|
||||
if self.argument:
|
||||
# validate
|
||||
argument = self.argument(argument)
|
||||
if isinstance(self.argument, TupleOf):
|
||||
res = func(*argument)
|
||||
elif isinstance(self.argument, StructOf):
|
||||
res = func(**argument)
|
||||
else:
|
||||
res = func(argument)
|
||||
else:
|
||||
if argument is not None:
|
||||
raise BadValueError('%s.%s takes no arguments' % (module_obj.__class__.__name__, self.name))
|
||||
res = func()
|
||||
if self.result:
|
||||
return self.result(res)
|
||||
return None # silently ignore the result from the method
|
||||
|
||||
def for_export(self):
|
||||
return self.exportProperties()
|
||||
|
||||
def __repr__(self):
|
||||
result = super().__repr__()
|
||||
return result[:-1] + ', %r)' % self.func if self.func else result
|
||||
|
||||
|
||||
# list of predefined accessibles with their type
|
||||
PREDEFINED_ACCESSIBLES = dict(
|
||||
value=Parameter,
|
||||
status=Parameter,
|
||||
target=Parameter,
|
||||
pollinterval=Parameter,
|
||||
ramp=Parameter,
|
||||
user_ramp=Parameter,
|
||||
setpoint=Parameter,
|
||||
time_to_target=Parameter,
|
||||
unit=Parameter, # reserved name
|
||||
loglevel=Parameter, # reserved name
|
||||
mode=Parameter, # reserved name
|
||||
stop=Command,
|
||||
reset=Command,
|
||||
go=Command,
|
||||
abort=Command,
|
||||
shutdown=Command,
|
||||
communicate=Command,
|
||||
)
|
||||
179
frappy/parse.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""parser. used for config files and the gui
|
||||
|
||||
can't use ast.literal_eval as we want less strict syntax (strings without quotes)
|
||||
|
||||
parsing rules:
|
||||
(...) -> tuple
|
||||
[...] -> tuple
|
||||
{text:...} -> dict
|
||||
{text=...} -> dict
|
||||
..., ... -> tuple
|
||||
digits -> float or int (try int, if it fails: take float)
|
||||
text -> string
|
||||
'text' -> string
|
||||
"text" -> string
|
||||
|
||||
further conversions are done by the validator of the datatype....
|
||||
"""
|
||||
|
||||
# TODO: should be refactored to use Exceptions instead of None in return tuple
|
||||
# also it would be better to use functions instead of a class
|
||||
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
class Parser:
|
||||
# all parsing methods return (parsed value, remaining string)
|
||||
# or (None, remaining_text) if parsing error
|
||||
|
||||
def parse_number(self, text):
|
||||
text = text.strip()
|
||||
l = 1
|
||||
number = None
|
||||
while l <= len(text):
|
||||
try:
|
||||
number = float(text[:l])
|
||||
length = l
|
||||
l += 1
|
||||
except ValueError:
|
||||
if text[l - 1] in 'eE+-':
|
||||
l += 1
|
||||
continue
|
||||
if number is None:
|
||||
return None, text
|
||||
try:
|
||||
# TODO: check allthough length is unset in it. 1, number is None, never reaching the try
|
||||
# pylint: disable=used-before-assignment
|
||||
return int(text[:length]), text[length:]
|
||||
except ValueError:
|
||||
return number, text[length:]
|
||||
return number, ''
|
||||
|
||||
def parse_string(self, orgtext):
|
||||
# handle quoted and unquoted strings correctly
|
||||
text = orgtext.strip()
|
||||
if text[0] in ('"', "'"):
|
||||
# quoted string
|
||||
quote = text[0]
|
||||
idx = 0
|
||||
|
||||
while True:
|
||||
idx = text.find(quote, idx + 1)
|
||||
if idx == -1:
|
||||
return None, orgtext
|
||||
# check escapes!
|
||||
if text[idx - 1] == '\\':
|
||||
continue
|
||||
return text[1:idx], text[idx + 1:].strip()
|
||||
|
||||
# unquoted strings are terminated by comma or whitespace
|
||||
idx = 0
|
||||
while idx < len(text):
|
||||
if text[idx] in '\x09 ,.;:()[]{}<>-+*/\\!"§$%&=?#~+*\'´`^°|-':
|
||||
break
|
||||
idx += 1
|
||||
return text[:idx] or None, text[idx:].strip()
|
||||
|
||||
def parse_tuple(self, orgtext):
|
||||
text = orgtext.strip()
|
||||
bra = text[0]
|
||||
if bra not in '([<':
|
||||
return None, orgtext
|
||||
# convert to closing bracket
|
||||
bra = ')]>'['([<'.index(bra)]
|
||||
reslist = []
|
||||
# search for closing bracket, collecting results
|
||||
text = text[1:]
|
||||
while text:
|
||||
if bra not in text:
|
||||
return None, text
|
||||
res, rem = self.parse_sub(text)
|
||||
if res is None:
|
||||
print('remtuple %r %r %r' % (rem, text, bra))
|
||||
if rem[0] == bra:
|
||||
# allow trailing separator
|
||||
return tuple(reslist), rem[1:].strip()
|
||||
return None, text
|
||||
reslist.append(res)
|
||||
if rem[0] == bra:
|
||||
return tuple(reslist), rem[1:].strip()
|
||||
# eat separator
|
||||
if rem[0] in ',;':
|
||||
text = rem[1:]
|
||||
else:
|
||||
return None, rem
|
||||
return None, orgtext
|
||||
|
||||
def parse_dict(self, orgtext):
|
||||
text = orgtext.strip()
|
||||
if text[0] != '{':
|
||||
return None, orgtext
|
||||
# keep ordering
|
||||
result = OrderedDict()
|
||||
# search for closing bracket, collecting results
|
||||
# watch for key=value or key:value pairs, separated by ,
|
||||
text = text[1:]
|
||||
while '}' in text:
|
||||
# first part is always a string
|
||||
key, rem = self.parse_string(text)
|
||||
if key is None:
|
||||
if rem[0] == '}':
|
||||
# allow trailing separator
|
||||
return result, rem[1:].strip()
|
||||
return None, orgtext
|
||||
if rem[0] not in ':=':
|
||||
return None, rem
|
||||
# eat separator
|
||||
text = rem[1:]
|
||||
value, rem = self.parse_sub(text)
|
||||
if value is None:
|
||||
return None, orgtext
|
||||
result[key] = value
|
||||
if rem[0] == '}':
|
||||
return result, rem[1:].strip()
|
||||
|
||||
if rem[0] not in ',;':
|
||||
return None, rem
|
||||
# eat separator
|
||||
text = rem[1:]
|
||||
return None, text
|
||||
|
||||
def parse_sub(self, orgtext):
|
||||
text = orgtext.strip()
|
||||
if not text:
|
||||
return None, orgtext
|
||||
if text[0] in '+-.0123456789':
|
||||
return self.parse_number(orgtext)
|
||||
if text[0] == '{':
|
||||
return self.parse_dict(orgtext)
|
||||
if text[0] in '([<':
|
||||
return self.parse_tuple(orgtext)
|
||||
return self.parse_string(orgtext)
|
||||
|
||||
def parse(self, orgtext):
|
||||
res, rem = self.parse_sub(orgtext)
|
||||
if rem and rem[0] in ',;':
|
||||
return self.parse_sub('[%s]' % orgtext)
|
||||
return res, rem
|
||||
151
frappy/persistent.py
Normal file
@@ -0,0 +1,151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Mixin for keeping parameters persistent
|
||||
|
||||
For hardware not keeping parameters persistent, we might want to store them in Frappy.
|
||||
|
||||
The following example will make 'param1' and 'param2' persistent, i.e. whenever
|
||||
one of the parameters is changed, either by a change command or when reading back
|
||||
from the hardware, it is saved to a file, and reloaded after
|
||||
a power down / power up cycle. In order to make this work properly, there is a
|
||||
mechanism needed to detect power down (i.e. a reading a hardware parameter
|
||||
taking a special value on power up).
|
||||
|
||||
An additional use might be the example of a motor with an encoder which looses
|
||||
the counts of how many turns already happened on power down.
|
||||
This can be solved by comparing the loaded encoder value self.encoder with a
|
||||
fresh value from the hardware and then adjusting the zero point accordingly.
|
||||
|
||||
|
||||
class MyClass(PersistentMixin, ...):
|
||||
param1 = PersistentParam(...)
|
||||
param2 = PersistentParam(...)
|
||||
encoder = PersistentParam(...)
|
||||
|
||||
...
|
||||
|
||||
def read_encoder(self):
|
||||
encoder = <get encoder from hardware>
|
||||
if <power down/power up cycle detected>:
|
||||
self.loadParameters()
|
||||
<fix encoder turns by comparing loaded self.encoder with encoder from hw>
|
||||
else:
|
||||
self.saveParameters()
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
from frappy.lib import generalConfig
|
||||
from frappy.datatypes import EnumType
|
||||
from frappy.params import Parameter, Property, Command
|
||||
from frappy.modules import HasAccessibles
|
||||
|
||||
|
||||
class PersistentParam(Parameter):
|
||||
persistent = Property('persistence flag (auto means: save automatically on any change)',
|
||||
EnumType(off=0, on=1, auto=2), default=1)
|
||||
|
||||
|
||||
class PersistentMixin(HasAccessibles):
|
||||
def __init__(self, *args, **kwds):
|
||||
super().__init__(*args, **kwds)
|
||||
persistentdir = os.path.join(generalConfig.logdir, 'persistent')
|
||||
os.makedirs(persistentdir, exist_ok=True)
|
||||
self.persistentFile = os.path.join(persistentdir, '%s.%s.json' % (self.DISPATCHER.equipment_id, self.name))
|
||||
self.initData = {} # "factory" settings
|
||||
for pname in self.parameters:
|
||||
pobj = self.parameters[pname]
|
||||
flag = getattr(pobj, 'persistent', 0)
|
||||
if flag:
|
||||
if flag == 'auto':
|
||||
def cb(value, m=self):
|
||||
m.saveParameters()
|
||||
self.valueCallbacks[pname].append(cb)
|
||||
self.initData[pname] = pobj.value
|
||||
self.writeDict.update(self.loadParameters(write=False))
|
||||
|
||||
def loadParameters(self, write=True):
|
||||
"""load persistent parameters
|
||||
|
||||
:return: persistent parameters which have to be written
|
||||
|
||||
is called upon startup and may be called from a module
|
||||
when a hardware powerdown is detected
|
||||
"""
|
||||
try:
|
||||
with open(self.persistentFile, 'r', encoding='utf-8') as f:
|
||||
self.persistentData = json.load(f)
|
||||
except Exception:
|
||||
self.persistentData = {}
|
||||
writeDict = {}
|
||||
for pname in self.parameters:
|
||||
pobj = self.parameters[pname]
|
||||
if getattr(pobj, 'persistent', False) and pname in self.persistentData:
|
||||
try:
|
||||
value = pobj.datatype.import_value(self.persistentData[pname])
|
||||
pobj.value = value
|
||||
pobj.readerror = None
|
||||
if not pobj.readonly:
|
||||
writeDict[pname] = value
|
||||
except Exception as e:
|
||||
self.log.warning('can not restore %r to %r (%r)' % (pname, value, e))
|
||||
if write:
|
||||
self.writeDict.update(writeDict)
|
||||
self.writeInitParams()
|
||||
return writeDict
|
||||
|
||||
def saveParameters(self):
|
||||
"""save persistent parameters
|
||||
|
||||
- to be called regularely explicitly by the module
|
||||
- the caller has to make sure that this is not called after
|
||||
a power down of the connected hardware before loadParameters
|
||||
"""
|
||||
if self.writeDict:
|
||||
# do not save before all values are written to the hw, as potentially
|
||||
# factory default values were read in the mean time
|
||||
return
|
||||
data = {k: v.export_value() for k, v in self.parameters.items()
|
||||
if getattr(v, 'persistent', False)}
|
||||
if data != self.persistentData:
|
||||
self.persistentData = data
|
||||
persistentdir = os.path.dirname(self.persistentFile)
|
||||
tmpfile = self.persistentFile + '.tmp'
|
||||
if not os.path.isdir(persistentdir):
|
||||
os.makedirs(persistentdir, exist_ok=True)
|
||||
try:
|
||||
with open(tmpfile, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.persistentData, f, indent=2)
|
||||
f.write('\n')
|
||||
os.rename(tmpfile, self.persistentFile)
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmpfile)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@Command()
|
||||
def factory_reset(self):
|
||||
"""reset to values from config / default values"""
|
||||
self.writeDict.update(self.initData)
|
||||
self.writeInitParams()
|
||||
194
frappy/properties.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define validated data types."""
|
||||
|
||||
|
||||
import inspect
|
||||
|
||||
from frappy.errors import BadValueError, ConfigError, ProgrammingError
|
||||
from frappy.lib import UniqueObject
|
||||
from frappy.lib.py35compat import Object
|
||||
|
||||
UNSET = UniqueObject('undefined value') #: an unset value, not even None
|
||||
|
||||
|
||||
class HasDescriptors(Object):
|
||||
@classmethod
|
||||
def __init_subclass__(cls):
|
||||
# when migrating old style declarations, sometimes the trailing comma is not removed
|
||||
bad = [k for k, v in cls.__dict__.items()
|
||||
if isinstance(v, tuple) and len(v) == 1 and hasattr(v[0], '__set_name__')]
|
||||
if bad:
|
||||
raise ProgrammingError('misplaced trailing comma after %s.%s' % (cls.__name__, '/'.join(bad)))
|
||||
|
||||
|
||||
# storage for 'properties of a property'
|
||||
class Property:
|
||||
"""base class holding info about a property
|
||||
|
||||
:param description: mandatory
|
||||
:param datatype: the datatype to be accepted. not only to the SECoP datatypes are allowed!
|
||||
also for example ``ValueType()`` (any type!), ``NoneOr(...)``, etc.
|
||||
:param default: a default value. SECoP properties are normally not sent to the ECS,
|
||||
when they match the default
|
||||
:param extname: external name
|
||||
:param export: sent to the ECS when True. defaults to True, when ``extname`` is given.
|
||||
special value 'always': export also when matching the default
|
||||
:param mandatory: defaults to True, when ``default`` is not given. indicates that it must have a value
|
||||
assigned from the cfg file (or, in case of a module property, it may be assigned as a class attribute)
|
||||
:param settable: settable from the cfg file
|
||||
"""
|
||||
|
||||
# note: this is intended to be used on base classes.
|
||||
# the VALUES of the properties are on the instances!
|
||||
def __init__(self, description, datatype, default=UNSET, extname='', export=False, mandatory=None,
|
||||
settable=True, value=UNSET, name=''):
|
||||
if not callable(datatype):
|
||||
raise ValueError('datatype MUST be a valid DataType or a basic_validator')
|
||||
self.description = inspect.cleandoc(description)
|
||||
self.default = datatype.default if default is UNSET else datatype(default)
|
||||
self.datatype = datatype
|
||||
self.extname = extname
|
||||
self.export = export or bool(extname)
|
||||
if mandatory is None:
|
||||
mandatory = default is UNSET
|
||||
self.mandatory = mandatory
|
||||
self.settable = settable or mandatory # settable means settable from the cfg file
|
||||
self.value = UNSET if value is UNSET else datatype(value)
|
||||
self.name = name
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if instance is None:
|
||||
return self
|
||||
return instance.propertyValues.get(self.name, self.default)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
instance.propertyValues[self.name] = self.datatype(value)
|
||||
|
||||
def __set_name__(self, owner, name):
|
||||
self.name = name
|
||||
if self.export and not self.extname:
|
||||
self.extname = '_' + name
|
||||
|
||||
def copy(self):
|
||||
return type(self)(**self.__dict__)
|
||||
|
||||
def __repr__(self):
|
||||
extras = ['default=%s' % repr(self.default)]
|
||||
if self.export:
|
||||
extras.append('extname=%r' % self.extname)
|
||||
extras.append('export=%r' % self.export)
|
||||
if self.mandatory:
|
||||
extras.append('mandatory=True')
|
||||
if not self.settable:
|
||||
extras.append('settable=False')
|
||||
if self.value is not UNSET:
|
||||
extras.append('value=%s' % repr(self.value))
|
||||
if not self.name:
|
||||
extras.append('name=%r' % self.name)
|
||||
return 'Property(%r, %s, %s)' % (self.description, self.datatype, ', '.join(extras))
|
||||
|
||||
|
||||
class HasProperties(HasDescriptors):
|
||||
"""mixin for classes with properties
|
||||
|
||||
- properties are collected in cls.propertyDict
|
||||
- bare values overriding properties should be kept as properties
|
||||
- include also attributes of type Property on base classes not inheriting HasProperties
|
||||
"""
|
||||
propertyValues = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# store property values in the instance, keep descriptors on the class
|
||||
self.propertyValues = {}
|
||||
# pre-init
|
||||
for pn, po in self.propertyDict.items():
|
||||
if po.value is not UNSET:
|
||||
self.setProperty(pn, po.value)
|
||||
|
||||
@classmethod
|
||||
def __init_subclass__(cls):
|
||||
super().__init_subclass__()
|
||||
properties = {}
|
||||
# using cls.__bases__ and base.propertyDict for this would fail on some multiple inheritance cases
|
||||
for base in reversed(cls.__mro__):
|
||||
properties.update({k: v for k, v in base.__dict__.items() if isinstance(v, Property)})
|
||||
cls.propertyDict = properties
|
||||
# treat overriding properties with bare values
|
||||
for pn, po in list(properties.items()):
|
||||
value = getattr(cls, pn, po)
|
||||
if isinstance(value, HasProperties): # value is a Parameter, allow override
|
||||
properties.pop(pn)
|
||||
elif not isinstance(value, Property): # attribute may be a bare value
|
||||
po = po.copy()
|
||||
try:
|
||||
# try to apply bare value to Property
|
||||
po.value = po.datatype(value)
|
||||
except BadValueError:
|
||||
if callable(value):
|
||||
raise ProgrammingError('method %s.%s collides with property of %s' %
|
||||
(cls.__name__, pn, base.__name__)) from None
|
||||
raise ProgrammingError('can not set property %s.%s to %r' %
|
||||
(cls.__name__, pn, value)) from None
|
||||
cls.propertyDict[pn] = po
|
||||
|
||||
def checkProperties(self):
|
||||
"""validates properties and checks for min... <= max..."""
|
||||
for pn, po in self.propertyDict.items():
|
||||
if po.mandatory:
|
||||
try:
|
||||
self.propertyValues[pn] = po.datatype(self.propertyValues[pn])
|
||||
except (KeyError, BadValueError):
|
||||
raise ConfigError('%s needs a value of type %r!' % (pn, po.datatype)) from None
|
||||
for pn, po in self.propertyDict.items():
|
||||
if pn.startswith('min'):
|
||||
maxname = 'max' + pn[3:]
|
||||
minval = self.propertyValues.get(pn, po.default)
|
||||
maxval = self.propertyValues.get(maxname, minval)
|
||||
if minval > maxval:
|
||||
raise ConfigError('%s=%r must be <= %s=%r for %r' % (pn, minval, maxname, maxval, self))
|
||||
|
||||
def getProperties(self):
|
||||
return self.propertyDict
|
||||
|
||||
def exportProperties(self):
|
||||
# export properties which have
|
||||
# export=True and
|
||||
# mandatory=True or non_default=True
|
||||
res = {}
|
||||
for pn, po in self.propertyDict.items():
|
||||
val = self.propertyValues.get(pn, po.default)
|
||||
if po.export and (po.export == 'always' or val != po.default):
|
||||
try:
|
||||
val = po.datatype.export_value(val)
|
||||
except AttributeError:
|
||||
pass # for properties, accept simple datatypes without export_value
|
||||
res[po.extname] = val
|
||||
return res
|
||||
|
||||
def setProperty(self, key, value):
|
||||
# this is overwritten by Param.setProperty and DataType.setProperty
|
||||
# in oder to extend setting to inner properties
|
||||
# otherwise direct setting of self.<key> = value is preferred
|
||||
self.propertyValues[key] = self.propertyDict[key].datatype(value)
|
||||
22
frappy/protocol/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""SECoP protocol specific stuff"""
|
||||
403
frappy/protocol/dispatcher.py
Normal file
@@ -0,0 +1,403 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Dispatcher for SECoP Messages
|
||||
|
||||
Interface to the service offering part:
|
||||
|
||||
- 'handle_request(connectionobj, data)' handles incoming request
|
||||
it returns the (sync) reply, and it may call 'send_reply(data)'
|
||||
on the connectionobj or on activated connections
|
||||
- 'add_connection(connectionobj)' registers new connection
|
||||
- 'remove_connection(connectionobj)' removes now longer functional connection
|
||||
|
||||
Interface to the modules:
|
||||
- add_module(modulename, moduleobj, export=True) registers a new module under the
|
||||
given name, may also register it for exporting (making accessible)
|
||||
- get_module(modulename) returns the requested module or None
|
||||
- remove_module(modulename_or_obj): removes the module (during shutdown)
|
||||
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from time import time as currenttime
|
||||
|
||||
from frappy.errors import NoSuchCommandError, NoSuchModuleError, \
|
||||
NoSuchParameterError, ProtocolError, ReadOnlyError, SECoPServerError
|
||||
from frappy.params import Parameter
|
||||
from frappy.protocol.messages import COMMANDREPLY, DESCRIPTIONREPLY, \
|
||||
DISABLEEVENTSREPLY, ENABLEEVENTSREPLY, ERRORPREFIX, EVENTREPLY, \
|
||||
HEARTBEATREPLY, IDENTREPLY, IDENTREQUEST, READREPLY, WRITEREPLY, \
|
||||
LOGGING_REPLY, LOG_EVENT
|
||||
|
||||
|
||||
def make_update(modulename, pobj):
|
||||
if pobj.readerror:
|
||||
return (ERRORPREFIX + EVENTREPLY, '%s:%s' % (modulename, pobj.export),
|
||||
# error-report !
|
||||
[pobj.readerror.name, repr(pobj.readerror), dict(t=pobj.timestamp)])
|
||||
return (EVENTREPLY, '%s:%s' % (modulename, pobj.export),
|
||||
[pobj.export_value(), dict(t=pobj.timestamp)])
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
def __init__(self, name, logger, options, srv):
|
||||
# to avoid errors, we want to eat all options here
|
||||
self.equipment_id = options.pop('id', name)
|
||||
# time interval for omitting updates of unchanged values
|
||||
self.omit_unchanged_within = options.pop('omit_unchanged_within', 0.1)
|
||||
self.nodeprops = {}
|
||||
for k in list(options):
|
||||
self.nodeprops[k] = options.pop(k)
|
||||
|
||||
self.log = logger
|
||||
# map ALL modulename -> moduleobj
|
||||
self._modules = {}
|
||||
# list of EXPORTED modules
|
||||
self._export = []
|
||||
# list all connections
|
||||
self._connections = []
|
||||
# active (i.e. broadcast-receiving) connections
|
||||
self._active_connections = set()
|
||||
# map eventname -> list of subscribed connections
|
||||
# eventname is <modulename> or <modulename>:<parametername>
|
||||
self._subscriptions = {}
|
||||
self._lock = threading.RLock()
|
||||
self.name = name
|
||||
self.restart = srv.restart
|
||||
self.shutdown = srv.shutdown
|
||||
|
||||
def broadcast_event(self, msg, reallyall=False):
|
||||
"""broadcasts a msg to all active connections
|
||||
|
||||
used from the dispatcher"""
|
||||
if reallyall:
|
||||
listeners = self._connections
|
||||
else:
|
||||
# all subscribers to module:param
|
||||
listeners = self._subscriptions.get(msg[1], set()).copy()
|
||||
# all subscribers to module
|
||||
module = msg[1].split(':', 1)[0]
|
||||
listeners.update(self._subscriptions.get(module, set()))
|
||||
# all generic subscribers
|
||||
listeners.update(self._active_connections)
|
||||
for conn in listeners:
|
||||
conn.send_reply(msg)
|
||||
|
||||
def announce_update(self, modulename, pname, pobj):
|
||||
"""called by modules param setters to notify subscribers of new values
|
||||
"""
|
||||
self.broadcast_event(make_update(modulename, pobj))
|
||||
|
||||
def subscribe(self, conn, eventname):
|
||||
self._subscriptions.setdefault(eventname, set()).add(conn)
|
||||
|
||||
def unsubscribe(self, conn, eventname):
|
||||
if ':' not in eventname:
|
||||
# also remove 'more specific' subscriptions
|
||||
for k, v in self._subscriptions.items():
|
||||
if k.startswith('%s:' % eventname):
|
||||
v.discard(conn)
|
||||
if eventname in self._subscriptions:
|
||||
self._subscriptions[eventname].discard(conn)
|
||||
|
||||
def add_connection(self, conn):
|
||||
"""registers new connection"""
|
||||
self._connections.append(conn)
|
||||
|
||||
def reset_connection(self, conn):
|
||||
"""remove all subscriptions for a connection
|
||||
|
||||
to be called on the identification message
|
||||
"""
|
||||
for _evt, conns in list(self._subscriptions.items()):
|
||||
conns.discard(conn)
|
||||
self.set_all_log_levels(conn, 'off')
|
||||
self._active_connections.discard(conn)
|
||||
|
||||
def remove_connection(self, conn):
|
||||
"""removes now longer functional connection"""
|
||||
if conn in self._connections:
|
||||
self._connections.remove(conn)
|
||||
self.reset_connection(conn)
|
||||
|
||||
def register_module(self, moduleobj, modulename, export=True):
|
||||
self.log.debug('registering module %r as %s (export=%r)' %
|
||||
(moduleobj, modulename, export))
|
||||
self._modules[modulename] = moduleobj
|
||||
if export:
|
||||
self._export.append(modulename)
|
||||
|
||||
def get_module(self, modulename):
|
||||
if modulename in self._modules:
|
||||
return self._modules[modulename]
|
||||
if modulename in list(self._modules.values()):
|
||||
return modulename
|
||||
raise NoSuchModuleError('Module %r does not exist on this SEC-Node!' % modulename)
|
||||
|
||||
def remove_module(self, modulename_or_obj):
|
||||
moduleobj = self.get_module(modulename_or_obj)
|
||||
modulename = moduleobj.name
|
||||
if modulename in self._export:
|
||||
self._export.remove(modulename)
|
||||
self._modules.pop(modulename)
|
||||
self._subscriptions.pop(modulename, None)
|
||||
for k in [kk for kk in self._subscriptions if kk.startswith('%s:' % modulename)]:
|
||||
self._subscriptions.pop(k, None)
|
||||
|
||||
def list_module_names(self):
|
||||
# return a copy of our list
|
||||
return self._export[:]
|
||||
|
||||
def export_accessibles(self, modulename):
|
||||
self.log.debug('export_accessibles(%r)' % modulename)
|
||||
if modulename in self._export:
|
||||
# omit export=False params!
|
||||
res = OrderedDict()
|
||||
for aobj in self.get_module(modulename).accessibles.values():
|
||||
if aobj.export:
|
||||
res[aobj.export] = aobj.for_export()
|
||||
self.log.debug('list accessibles for module %s -> %r' %
|
||||
(modulename, res))
|
||||
return res
|
||||
self.log.debug('-> module is not to be exported!')
|
||||
return OrderedDict()
|
||||
|
||||
def get_descriptive_data(self, specifier):
|
||||
"""returns a python object which upon serialisation results in the descriptive data"""
|
||||
modules = {}
|
||||
result = {'modules': modules}
|
||||
for modulename in self._export:
|
||||
module = self.get_module(modulename)
|
||||
if not module.export:
|
||||
continue
|
||||
# some of these need rework !
|
||||
mod_desc = {'accessibles': self.export_accessibles(modulename)}
|
||||
mod_desc.update(module.exportProperties())
|
||||
mod_desc.pop('export', False)
|
||||
modules[modulename] = mod_desc
|
||||
modname, _, pname = (specifier or '').partition(':')
|
||||
if modname in modules: # extension to SECoP standard: description of a single module
|
||||
result = modules[modname]
|
||||
if pname in result['accessibles']: # extension to SECoP standard: description of a single accessible
|
||||
# command is also accepted
|
||||
result = result['accessibles'][pname]
|
||||
elif pname:
|
||||
raise NoSuchParameterError('Module %r has no parameter %r' % (modname, pname))
|
||||
elif not modname or modname == '.':
|
||||
result['equipment_id'] = self.equipment_id
|
||||
result['firmware'] = 'FRAPPY - The Python Framework for SECoP'
|
||||
result['version'] = '2021.02'
|
||||
result.update(self.nodeprops)
|
||||
else:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modname)
|
||||
return result
|
||||
|
||||
def _execute_command(self, modulename, exportedname, argument=None):
|
||||
moduleobj = self.get_module(modulename)
|
||||
if moduleobj is None:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modulename)
|
||||
|
||||
cname = moduleobj.accessiblename2attr.get(exportedname)
|
||||
cobj = moduleobj.commands.get(cname)
|
||||
if cobj is None:
|
||||
raise NoSuchCommandError('Module %r has no command %r' % (modulename, cname or exportedname))
|
||||
|
||||
if cobj.argument:
|
||||
argument = cobj.argument.import_value(argument)
|
||||
# now call func
|
||||
# note: exceptions are handled in handle_request, not here!
|
||||
result = cobj.do(moduleobj, argument)
|
||||
if cobj.result:
|
||||
result = cobj.result.export_value(result)
|
||||
return result, dict(t=currenttime())
|
||||
|
||||
def _setParameterValue(self, modulename, exportedname, value):
|
||||
moduleobj = self.get_module(modulename)
|
||||
if moduleobj is None:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modulename)
|
||||
|
||||
pname = moduleobj.accessiblename2attr.get(exportedname)
|
||||
pobj = moduleobj.parameters.get(pname)
|
||||
if pobj is None:
|
||||
raise NoSuchParameterError('Module %r has no parameter %r' % (modulename, pname or exportedname))
|
||||
if pobj.constant is not None:
|
||||
raise ReadOnlyError("Parameter %s:%s is constant and can not be changed remotely"
|
||||
% (modulename, pname))
|
||||
if pobj.readonly:
|
||||
raise ReadOnlyError("Parameter %s:%s can not be changed remotely"
|
||||
% (modulename, pname))
|
||||
|
||||
# validate!
|
||||
value = pobj.datatype(value)
|
||||
# note: exceptions are handled in handle_request, not here!
|
||||
getattr(moduleobj, 'write_' + pname)(value)
|
||||
# return value is ignored here, as already handled
|
||||
return pobj.export_value(), dict(t=pobj.timestamp) if pobj.timestamp else {}
|
||||
|
||||
def _getParameterValue(self, modulename, exportedname):
|
||||
moduleobj = self.get_module(modulename)
|
||||
if moduleobj is None:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modulename)
|
||||
|
||||
pname = moduleobj.accessiblename2attr.get(exportedname)
|
||||
pobj = moduleobj.parameters.get(pname)
|
||||
if pobj is None:
|
||||
raise NoSuchParameterError('Module %r has no parameter %r' % (modulename, pname or exportedname))
|
||||
if pobj.constant is not None:
|
||||
# really needed? we could just construct a readreply instead....
|
||||
# raise ReadOnlyError('This parameter is constant and can not be accessed remotely.')
|
||||
return pobj.datatype.export_value(pobj.constant)
|
||||
|
||||
# note: exceptions are handled in handle_request, not here!
|
||||
getattr(moduleobj, 'read_' + pname)()
|
||||
# return value is ignored here, as already handled
|
||||
return pobj.export_value(), dict(t=pobj.timestamp) if pobj.timestamp else {}
|
||||
|
||||
#
|
||||
# api to be called from the 'interface'
|
||||
# any method above has no idea about 'messages', this is handled here
|
||||
#
|
||||
def handle_request(self, conn, msg):
|
||||
"""handles incoming request
|
||||
|
||||
will return return reply, may send replies to conn or
|
||||
activated connections in addition
|
||||
"""
|
||||
self.log.debug('Dispatcher: handling msg: %s' % repr(msg))
|
||||
|
||||
# play thread safe !
|
||||
# XXX: ONLY ONE REQUEST (per dispatcher) AT A TIME
|
||||
with self._lock:
|
||||
action, specifier, data = msg
|
||||
# special case for *IDN?
|
||||
if action == IDENTREQUEST:
|
||||
action, specifier, data = '_ident', None, None
|
||||
|
||||
self.log.debug('Looking for handle_%s' % action)
|
||||
handler = getattr(self, 'handle_%s' % action, None)
|
||||
|
||||
if handler:
|
||||
return handler(conn, specifier, data)
|
||||
raise SECoPServerError('unhandled message: %s' % repr(msg))
|
||||
|
||||
# now the (defined) handlers for the different requests
|
||||
def handle_help(self, conn, specifier, data):
|
||||
self.log.error('should have been handled in the interface!')
|
||||
|
||||
def handle__ident(self, conn, specifier, data):
|
||||
# Remark: the following line is needed due to issue 66.
|
||||
self.reset_connection(conn)
|
||||
# The other stuff in issue 66 ('error_closed' message), has to be implemented
|
||||
# if and when frappy will support serial server connections
|
||||
return (IDENTREPLY, None, None)
|
||||
|
||||
def handle_describe(self, conn, specifier, data):
|
||||
return (DESCRIPTIONREPLY, specifier or '.', self.get_descriptive_data(specifier))
|
||||
|
||||
def handle_read(self, conn, specifier, data):
|
||||
if data:
|
||||
raise ProtocolError('read requests don\'t take data!')
|
||||
modulename, pname = specifier, 'value'
|
||||
if ':' in specifier:
|
||||
modulename, pname = specifier.split(':', 1)
|
||||
# XXX: trigger polling and force sending event ???
|
||||
return (READREPLY, specifier, list(self._getParameterValue(modulename, pname)))
|
||||
|
||||
def handle_change(self, conn, specifier, data):
|
||||
modulename, pname = specifier, 'target'
|
||||
if ':' in specifier:
|
||||
modulename, pname = specifier.split(':', 1)
|
||||
return (WRITEREPLY, specifier, list(self._setParameterValue(modulename, pname, data)))
|
||||
|
||||
def handle_do(self, conn, specifier, data):
|
||||
modulename, cmd = specifier.split(':', 1)
|
||||
return (COMMANDREPLY, specifier, list(self._execute_command(modulename, cmd, data)))
|
||||
|
||||
def handle_ping(self, conn, specifier, data):
|
||||
if data:
|
||||
raise ProtocolError('ping requests don\'t take data!')
|
||||
return (HEARTBEATREPLY, specifier, [None, {'t': currenttime()}])
|
||||
|
||||
def handle_activate(self, conn, specifier, data):
|
||||
if data:
|
||||
raise ProtocolError('activate requests don\'t take data!')
|
||||
if specifier:
|
||||
modulename, exportedname = specifier, None
|
||||
if ':' in specifier:
|
||||
modulename, exportedname = specifier.split(':', 1)
|
||||
if modulename not in self._export:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modulename)
|
||||
moduleobj = self.get_module(modulename)
|
||||
if exportedname is not None:
|
||||
pname = moduleobj.accessiblename2attr.get(exportedname, True)
|
||||
if pname and pname not in moduleobj.accessibles:
|
||||
# what if we try to subscribe a command here ???
|
||||
raise NoSuchParameterError('Module %r has no parameter %r' % (modulename, pname))
|
||||
modules = [(modulename, pname)]
|
||||
else:
|
||||
modules = [(modulename, None)]
|
||||
# activate only ONE item (module or module:parameter)
|
||||
self.subscribe(conn, specifier)
|
||||
else:
|
||||
# activate all modules
|
||||
self._active_connections.add(conn)
|
||||
modules = [(m, None) for m in self._export]
|
||||
|
||||
# send updates for all subscribed values.
|
||||
# note: The initial poll already happend before the server is active
|
||||
for modulename, pname in modules:
|
||||
moduleobj = self._modules.get(modulename, None)
|
||||
if pname:
|
||||
conn.send_reply(make_update(modulename, moduleobj.parameters[pname]))
|
||||
continue
|
||||
for pobj in moduleobj.accessibles.values():
|
||||
if isinstance(pobj, Parameter) and pobj.export:
|
||||
conn.send_reply(make_update(modulename, pobj))
|
||||
return (ENABLEEVENTSREPLY, specifier, None) if specifier else (ENABLEEVENTSREPLY, None, None)
|
||||
|
||||
def handle_deactivate(self, conn, specifier, data):
|
||||
if data:
|
||||
raise ProtocolError('deactivate requests don\'t take data!')
|
||||
if specifier:
|
||||
self.unsubscribe(conn, specifier)
|
||||
else:
|
||||
self._active_connections.discard(conn)
|
||||
# XXX: also check all entries in self._subscriptions?
|
||||
return (DISABLEEVENTSREPLY, None, None)
|
||||
|
||||
def send_log_msg(self, conn, modname, level, msg):
|
||||
"""send log message """
|
||||
conn.send_reply((LOG_EVENT, '%s:%s' % (modname, level), msg))
|
||||
|
||||
def set_all_log_levels(self, conn, level):
|
||||
for modobj in self._modules.values():
|
||||
modobj.setRemoteLogging(conn, level)
|
||||
|
||||
def handle_logging(self, conn, specifier, level):
|
||||
if specifier and specifier != '.':
|
||||
modobj = self._modules[specifier]
|
||||
modobj.setRemoteLogging(conn, level)
|
||||
else:
|
||||
self.set_all_log_levels(conn, level)
|
||||
return LOGGING_REPLY, specifier, level
|
||||
52
frappy/protocol/interface/__init__.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
import json
|
||||
|
||||
EOL = b'\n'
|
||||
|
||||
|
||||
def encode_msg_frame(action, specifier=None, data=None):
|
||||
""" encode a msg_triple into an msg_frame, ready to be sent
|
||||
|
||||
action (and optional specifier) are str strings,
|
||||
data may be an json-yfied python object"""
|
||||
msg = (action, specifier or '', '' if data is None else json.dumps(data))
|
||||
return ' '.join(msg).strip().encode('utf-8') + EOL
|
||||
|
||||
|
||||
def get_msg(_bytes):
|
||||
"""try to deframe the next msg in (binary) input
|
||||
always return a tuple (msg, remaining_input)
|
||||
msg may also be None
|
||||
"""
|
||||
if EOL not in _bytes:
|
||||
return None, _bytes
|
||||
return _bytes.split(EOL, 1)
|
||||
|
||||
|
||||
def decode_msg(msg):
|
||||
"""decode the (binary) msg into a (str) msg_triple"""
|
||||
res = msg.strip().decode('utf-8').split(' ', 2) + ['', '']
|
||||
action, specifier, data = res[0:3]
|
||||
return action, specifier or None, None if data == '' else json.loads(data)
|
||||
212
frappy/protocol/interface/tcp.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""provides tcp interface to the SECoP Server"""
|
||||
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import errno
|
||||
|
||||
from frappy.datatypes import BoolType, StringType
|
||||
from frappy.errors import SECoPError
|
||||
from frappy.lib import formatException, \
|
||||
formatExtendedStack, formatExtendedTraceback
|
||||
from frappy.properties import Property
|
||||
from frappy.protocol.interface import decode_msg, encode_msg_frame, get_msg
|
||||
from frappy.protocol.messages import ERRORPREFIX, \
|
||||
HELPREPLY, HELPREQUEST, HelpMessage
|
||||
|
||||
DEF_PORT = 10767
|
||||
MESSAGE_READ_SIZE = 1024
|
||||
HELP = HELPREQUEST.encode()
|
||||
|
||||
|
||||
class TCPRequestHandler(socketserver.BaseRequestHandler):
|
||||
|
||||
def setup(self):
|
||||
self.log = self.server.log
|
||||
self.running = True
|
||||
self.send_lock = threading.Lock()
|
||||
|
||||
def handle(self):
|
||||
"""handle a new tcp-connection"""
|
||||
# copy state info
|
||||
mysocket = self.request
|
||||
clientaddr = self.client_address
|
||||
serverobj = self.server
|
||||
|
||||
self.log.info("handling new connection from %s:%d" % clientaddr)
|
||||
data = b''
|
||||
|
||||
# notify dispatcher of us
|
||||
serverobj.dispatcher.add_connection(self)
|
||||
|
||||
# copy relevant settings from Interface
|
||||
detailed_errors = serverobj.detailed_errors
|
||||
|
||||
mysocket.settimeout(1)
|
||||
# start serving
|
||||
while self.running:
|
||||
try:
|
||||
newdata = mysocket.recv(MESSAGE_READ_SIZE)
|
||||
if not newdata:
|
||||
# no timeout error, but no new data -> connection closed
|
||||
return
|
||||
data = data + newdata
|
||||
except socket.timeout:
|
||||
continue
|
||||
except socket.error as e:
|
||||
self.log.exception(e)
|
||||
return
|
||||
if not data:
|
||||
continue
|
||||
# put data into (de-) framer,
|
||||
# put frames into (de-) coder and if a message appear,
|
||||
# call dispatcher.handle_request(self, message)
|
||||
# dispatcher will queue the reply before returning
|
||||
while self.running:
|
||||
origin, data = get_msg(data)
|
||||
if origin is None:
|
||||
break # no more messages to process
|
||||
origin = origin.strip()
|
||||
if origin in (HELP, b''): # empty string -> send help message
|
||||
for idx, line in enumerate(HelpMessage.splitlines()):
|
||||
# not sending HELPREPLY here, as there should be only one reply for every request
|
||||
self.send_reply(('_', '%d' % (idx+1), line))
|
||||
# ident matches request
|
||||
self.send_reply((HELPREPLY, None, None))
|
||||
continue
|
||||
try:
|
||||
msg = decode_msg(origin)
|
||||
except Exception as err:
|
||||
# we have to decode 'origin' here
|
||||
# use latin-1, as utf-8 or ascii may lead to encoding errors
|
||||
msg = origin.decode('latin-1').split(' ', 3) + [None] # make sure len(msg) > 1
|
||||
result = (ERRORPREFIX + msg[0], msg[1], ['InternalError', str(err),
|
||||
{'exception': formatException(),
|
||||
'traceback': formatExtendedStack()}])
|
||||
print('--------------------')
|
||||
print(formatException())
|
||||
print('--------------------')
|
||||
print(formatExtendedTraceback(sys.exc_info()))
|
||||
print('====================')
|
||||
else:
|
||||
try:
|
||||
result = serverobj.dispatcher.handle_request(self, msg)
|
||||
except SECoPError as err:
|
||||
result = (ERRORPREFIX + msg[0], msg[1], [err.name, str(err),
|
||||
{'exception': formatException(),
|
||||
'traceback': formatExtendedStack()}])
|
||||
except Exception as err:
|
||||
# create Error Obj instead
|
||||
result = (ERRORPREFIX + msg[0], msg[1], ['InternalError', repr(err),
|
||||
{'exception': formatException(),
|
||||
'traceback': formatExtendedStack()}])
|
||||
print('--------------------')
|
||||
print(formatException())
|
||||
print('--------------------')
|
||||
print(formatExtendedTraceback(sys.exc_info()))
|
||||
print('====================')
|
||||
|
||||
if not result:
|
||||
self.log.error('empty result upon msg %s' % repr(msg))
|
||||
if result[0].startswith(ERRORPREFIX) and not detailed_errors:
|
||||
# strip extra information
|
||||
result[2][2].clear()
|
||||
self.send_reply(result)
|
||||
|
||||
def send_reply(self, data):
|
||||
"""send reply
|
||||
|
||||
stops recv loop on error (including timeout when output buffer full for more than 1 sec)
|
||||
"""
|
||||
if not data:
|
||||
self.log.error('should not reply empty data!')
|
||||
return
|
||||
outdata = encode_msg_frame(*data)
|
||||
with self.send_lock:
|
||||
if self.running:
|
||||
try:
|
||||
self.request.sendall(outdata)
|
||||
except Exception as e:
|
||||
self.log.error('ERROR in send_reply %r', e)
|
||||
self.running = False
|
||||
|
||||
def finish(self):
|
||||
"""called when handle() terminates, i.e. the socket closed"""
|
||||
self.log.info('closing connection from %s:%d' % self.client_address)
|
||||
# notify dispatcher
|
||||
self.server.dispatcher.remove_connection(self)
|
||||
# close socket
|
||||
try:
|
||||
self.request.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self.request.close()
|
||||
|
||||
|
||||
class TCPServer(socketserver.ThreadingTCPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
# for cfg-editor
|
||||
configurables = {
|
||||
'uri': Property('hostname or ip address for binding', StringType(),
|
||||
default='tcp://%d' % DEF_PORT, export=False),
|
||||
'detailed_errors': Property('Flag to enable detailed Errorreporting.', BoolType(),
|
||||
default=False, export=False),
|
||||
}
|
||||
|
||||
def __init__(self, name, logger, options, srv):
|
||||
self.dispatcher = srv.dispatcher
|
||||
self.name = name
|
||||
self.log = logger
|
||||
port = int(options.pop('uri').split('://', 1)[-1])
|
||||
self.detailed_errors = options.pop('detailed_errors', False)
|
||||
|
||||
self.log.info("TCPServer %s binding to port %d" % (name, port))
|
||||
for ntry in range(5):
|
||||
try:
|
||||
socketserver.ThreadingTCPServer.__init__(
|
||||
self, ('0.0.0.0', port), TCPRequestHandler, bind_and_activate=True)
|
||||
break
|
||||
except OSError as e:
|
||||
if e.args[0] == errno.EADDRINUSE: # address already in use
|
||||
# this may happen despite of allow_reuse_address
|
||||
time.sleep(0.3 * (1 << ntry)) # max accumulated sleep time: 0.3 * 31 = 9.3 sec
|
||||
else:
|
||||
self.log.error('could not initialize TCP Server: %r' % e)
|
||||
raise
|
||||
if ntry:
|
||||
self.log.warning('tried again %d times after "Address already in use"' % ntry)
|
||||
self.log.info("TCPServer initiated")
|
||||
|
||||
# py35 compatibility
|
||||
if not hasattr(socketserver.ThreadingTCPServer, '__exit__'):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.server_close()
|
||||
28
frappy/protocol/interface/zmq.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""provide a zmq server"""
|
||||
|
||||
|
||||
# tbd.
|
||||
|
||||
# use zmq frames??
|
||||
# handle async and sync with different zmq ports?
|
||||
104
frappy/protocol/messages.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define SECoP Messages"""
|
||||
|
||||
# allowed actions:
|
||||
|
||||
IDENTREQUEST = '*IDN?' # literal
|
||||
# literal! first part is fixed!
|
||||
IDENTPREFIX = 'SINE2020&ISSE,SECoP,'
|
||||
IDENTREPLY = IDENTPREFIX + 'V2019-08-20,v1.0 RC2'
|
||||
|
||||
DESCRIPTIONREQUEST = 'describe' # literal
|
||||
DESCRIPTIONREPLY = 'describing' # +<id> +json
|
||||
|
||||
ENABLEEVENTSREQUEST = 'activate' # literal + optional spec
|
||||
ENABLEEVENTSREPLY = 'active' # literal + optional spec, is end-of-initial-data-transfer
|
||||
|
||||
DISABLEEVENTSREQUEST = 'deactivate' # literal + optional spec
|
||||
DISABLEEVENTSREPLY = 'inactive' # literal + optional spec
|
||||
|
||||
COMMANDREQUEST = 'do' # +module:command +json args (if needed)
|
||||
# +module:command +json args (if needed) # send after the command finished !
|
||||
COMMANDREPLY = 'done'
|
||||
|
||||
# +module[:parameter] +json_value
|
||||
WRITEREQUEST = 'change'
|
||||
# +module[:parameter] +json_value # send with the read back value
|
||||
WRITEREPLY = 'changed'
|
||||
|
||||
# +module[:parameter] +json_value
|
||||
BUFFERREQUEST = 'buffer'
|
||||
# +module[:parameter] +json_value # send with the read back value
|
||||
BUFFERREPLY = 'buffered'
|
||||
|
||||
# +module[:parameter] -> NO direct reply, calls POLL internally!
|
||||
READREQUEST = 'read'
|
||||
READREPLY = 'reply' # See Issue 54
|
||||
|
||||
EVENTREPLY = 'update' # +module[:parameter] +json_value (value, qualifiers_as_dict)
|
||||
|
||||
HEARTBEATREQUEST = 'ping' # +nonce_without_space
|
||||
HEARTBEATREPLY = 'pong' # +nonce_without_space
|
||||
|
||||
ERRORPREFIX = 'error_' # + specifier + json_extended_info(error_report)
|
||||
|
||||
HELPREQUEST = 'help' # literal
|
||||
HELPREPLY = 'helping' # +line number +json_text
|
||||
|
||||
LOGGING_REQUEST = 'logging'
|
||||
LOGGING_REPLY = 'logging'
|
||||
# + [module] + json string (loglevel)
|
||||
|
||||
LOG_EVENT = 'log'
|
||||
# + [module:level] + json_string (message)
|
||||
|
||||
# helper mapping to find the REPLY for a REQUEST
|
||||
# do not put IDENTREQUEST/IDENTREPLY here, as this needs anyway extra treatment
|
||||
REQUEST2REPLY = {
|
||||
DESCRIPTIONREQUEST: DESCRIPTIONREPLY,
|
||||
ENABLEEVENTSREQUEST: ENABLEEVENTSREPLY,
|
||||
DISABLEEVENTSREQUEST: DISABLEEVENTSREPLY,
|
||||
COMMANDREQUEST: COMMANDREPLY,
|
||||
WRITEREQUEST: WRITEREPLY,
|
||||
BUFFERREQUEST: BUFFERREPLY,
|
||||
READREQUEST: READREPLY,
|
||||
HEARTBEATREQUEST: HEARTBEATREPLY,
|
||||
HELPREQUEST: HELPREPLY,
|
||||
LOGGING_REQUEST: LOGGING_REPLY,
|
||||
}
|
||||
|
||||
|
||||
HelpMessage = """Try one of the following:
|
||||
'%s' to query protocol version
|
||||
'%s' to read the description
|
||||
'%s <module>[:<parameter>]' to request reading a value
|
||||
'%s <module>[:<parameter>] value' to request changing a value
|
||||
'%s <module>[:<command>]' to execute a command
|
||||
'%s <nonce>' to request a heartbeat response
|
||||
'%s' to activate async updates
|
||||
'%s' to deactivate updates
|
||||
'%s [<module>] <loglevel>' to activate logging events
|
||||
""" % (IDENTREQUEST, DESCRIPTIONREQUEST, READREQUEST,
|
||||
WRITEREQUEST, COMMANDREQUEST, HEARTBEATREQUEST,
|
||||
ENABLEEVENTSREQUEST, DISABLEEVENTSREQUEST,
|
||||
LOGGING_REQUEST)
|
||||
190
frappy/protocol/router.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Secop Router
|
||||
|
||||
this is a replacement for the standard dispatcher, with the
|
||||
additional functionality of routing message from/to several other SEC nodes
|
||||
|
||||
simplifications:
|
||||
- module wise activation not supported
|
||||
- on connection, the description from all nodes are cached and all nodes are activated
|
||||
- on 'describe' and on 'activate', cached values are returned
|
||||
- ping is not forwarded
|
||||
- what to do on a change of descriptive data is not yet implemented
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import frappy.client
|
||||
import frappy.errors
|
||||
import frappy.protocol.dispatcher
|
||||
from frappy.lib.multievent import MultiEvent
|
||||
from frappy.protocol.messages import COMMANDREQUEST, DESCRIPTIONREPLY, \
|
||||
ENABLEEVENTSREPLY, ERRORPREFIX, EVENTREPLY, READREQUEST, WRITEREQUEST
|
||||
|
||||
|
||||
class SecopClient(frappy.client.SecopClient):
|
||||
disconnectedExc = frappy.errors.CommunicationFailedError('remote SEC node disconnected')
|
||||
disconnectedError = (disconnectedExc.name, str(disconnectedExc))
|
||||
|
||||
def __init__(self, uri, log, dispatcher):
|
||||
self.dispatcher = dispatcher
|
||||
super().__init__(uri, log)
|
||||
|
||||
def internalize_name(self, name):
|
||||
"""do not modify names"""
|
||||
return name
|
||||
|
||||
def updateEvent(self, module, parameter, value, timestamp, readerror):
|
||||
specifier = '%s:%s' % (module, parameter)
|
||||
if readerror:
|
||||
msg = ERRORPREFIX + EVENTREPLY, specifier, (readerror.name, str(readerror), dict(t=timestamp))
|
||||
else:
|
||||
msg = EVENTREPLY, specifier, (value, dict(t=timestamp))
|
||||
self.dispatcher.broadcast_event(msg)
|
||||
|
||||
def nodeStateChange(self, online, state):
|
||||
t = time.time()
|
||||
if not online:
|
||||
for key, (value, _, readerror) in self.cache.items():
|
||||
if not readerror:
|
||||
self.cache[key] = value, t, self.disconnectedExc
|
||||
self.updateEvent(*key, *self.cache[key])
|
||||
|
||||
def descriptiveDataChange(self, module, data):
|
||||
if module is None:
|
||||
self.dispatcher.restart()
|
||||
self._shutdown = True
|
||||
raise frappy.errors.SECoPError('descriptive data for node %r has changed' % self.nodename)
|
||||
|
||||
|
||||
class Router(frappy.protocol.dispatcher.Dispatcher):
|
||||
singlenode = None
|
||||
|
||||
def __init__(self, name, logger, options, srv):
|
||||
"""initialize router
|
||||
|
||||
Use the option node = <uri> for a single node or
|
||||
nodes = ["<uri1>", "<uri2>" ...] for multiple nodes.
|
||||
If a single node is given, the node properties are forwarded transparently,
|
||||
else the description property is a merge from all client node properties.
|
||||
"""
|
||||
uri = options.pop('node', None)
|
||||
uris = options.pop('nodes', None)
|
||||
if uri and uris:
|
||||
raise frappy.errors.ConfigError('can not specify node _and_ nodes')
|
||||
super().__init__(name, logger, options, srv)
|
||||
if uri:
|
||||
self.nodes = [SecopClient(uri, logger.getChild('routed'), self)]
|
||||
self.singlenode = self.nodes[0]
|
||||
else:
|
||||
self.nodes = [SecopClient(uri, logger.getChild('routed%d' % i), self) for i, uri in enumerate(uris)]
|
||||
# register callbacks
|
||||
for node in self.nodes:
|
||||
node.register_callback(None, node.updateEvent, node.descriptiveDataChange, node.nodeStateChange)
|
||||
|
||||
self.restart = srv.restart
|
||||
self.node_by_module = {}
|
||||
multievent = MultiEvent()
|
||||
for node in self.nodes:
|
||||
node.spawn_connect(multievent.new().set)
|
||||
multievent.wait(10) # wait for all nodes started
|
||||
nodes = []
|
||||
for node in self.nodes:
|
||||
if node.online:
|
||||
for module in node.modules:
|
||||
self.node_by_module[module] = node
|
||||
nodes.append(node)
|
||||
else:
|
||||
|
||||
def nodeStateChange(online, state, self=self, node=node):
|
||||
if online:
|
||||
for module in node.modules:
|
||||
self.node_by_module[module] = node
|
||||
self.nodes.append(node)
|
||||
self.restart()
|
||||
return frappy.client.UNREGISTER
|
||||
return None
|
||||
|
||||
node.register_callback(None, nodeStateChange)
|
||||
logger.warning('can not connect to node %r', node.nodename)
|
||||
|
||||
def handle_describe(self, conn, specifier, data):
|
||||
if self.singlenode:
|
||||
return DESCRIPTIONREPLY, specifier, self.singlenode.descriptive_data
|
||||
reply = super().handle_describe(conn, specifier, data)
|
||||
result = reply[2]
|
||||
allmodules = result.get('modules', {})
|
||||
node_description = [result['description']]
|
||||
for node in self.nodes:
|
||||
data = node.descriptive_data.copy()
|
||||
modules = data.pop('modules')
|
||||
equipment_id = data.pop('equipment_id', 'unknown')
|
||||
node_description.append('--- %s ---\n%s' % (equipment_id, data.pop('description', '')))
|
||||
node_description.append('\n'.join('%s: %r' % kv for kv in data.items()))
|
||||
for modname, moddesc in modules.items():
|
||||
if modname in allmodules:
|
||||
self.log.info('module %r is already present', modname)
|
||||
else:
|
||||
allmodules[modname] = moddesc
|
||||
result['modules'] = allmodules
|
||||
result['description'] = '\n\n'.join(node_description)
|
||||
return DESCRIPTIONREPLY, specifier, result
|
||||
|
||||
def handle_activate(self, conn, specifier, data):
|
||||
super().handle_activate(conn, specifier, data)
|
||||
for node in self.nodes:
|
||||
for (module, parameter), (value, t, readerror) in node.cache.items():
|
||||
spec = '%s:%s' % (module, parameter)
|
||||
if readerror:
|
||||
reply = ERRORPREFIX + EVENTREPLY, spec, (readerror.name, str(readerror), dict(t=t))
|
||||
else:
|
||||
datatype = node.modules[module]['parameters'][parameter]['datatype']
|
||||
reply = EVENTREPLY, spec, [datatype.export_value(value), dict(t=t)]
|
||||
self.broadcast_event(reply)
|
||||
return ENABLEEVENTSREPLY, None, None
|
||||
|
||||
def handle_deactivate(self, conn, specifier, data):
|
||||
if specifier:
|
||||
raise frappy.errors.NotImplementedError('module wise activation not implemented')
|
||||
super().handle_deactivate(conn, specifier, data)
|
||||
|
||||
def handle_read(self, conn, specifier, data):
|
||||
module = specifier.split(':')[0]
|
||||
if module in self._modules:
|
||||
return super().handle_read(conn, specifier, data)
|
||||
node = self.node_by_module[module]
|
||||
if node.online:
|
||||
return node.request(READREQUEST, specifier, data)
|
||||
return ERRORPREFIX + READREQUEST, specifier, SecopClient.disconnectedError + (dict(t=node.disconnect_time),)
|
||||
|
||||
def handle_change(self, conn, specifier, data):
|
||||
module = specifier.split(':')[0]
|
||||
if module in self._modules:
|
||||
return super().handle_change(conn, specifier, data)
|
||||
return self.node_by_module[module].request(WRITEREQUEST, specifier, data)
|
||||
|
||||
def handle_do(self, conn, specifier, data):
|
||||
module = specifier.split(':')[0]
|
||||
if module in self._modules:
|
||||
return super().handle_do(conn, specifier, data)
|
||||
return self.node_by_module[module].request(COMMANDREQUEST, specifier, data)
|
||||
232
frappy/proxy.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""SECoP proxy modules"""
|
||||
|
||||
from frappy.client import SecopClient, decode_msg, encode_msg_frame
|
||||
from frappy.datatypes import StringType
|
||||
from frappy.errors import BadValueError, CommunicationFailedError, ConfigError
|
||||
from frappy.lib import get_class
|
||||
from frappy.modules import Drivable, Module, Readable, Writable
|
||||
from frappy.params import Command, Parameter
|
||||
from frappy.properties import Property
|
||||
from frappy.io import HasIO
|
||||
|
||||
|
||||
class ProxyModule(HasIO, Module):
|
||||
module = Property('remote module name', datatype=StringType(), default='')
|
||||
status = Parameter('connection status', Readable.status.datatype) # add status even when not a Readable
|
||||
|
||||
_consistency_check_done = False
|
||||
_secnode = None
|
||||
enablePoll = False
|
||||
|
||||
def ioClass(self, name, logger, opts, srv):
|
||||
opts['description'] = 'secnode %s on %s' % (opts.get('module', name), opts['uri'])
|
||||
return SecNode(name, logger, opts, srv)
|
||||
|
||||
def updateEvent(self, module, parameter, value, timestamp, readerror):
|
||||
if parameter not in self.parameters:
|
||||
return # ignore unknown parameters
|
||||
# should be done here: deal with clock differences
|
||||
self.announceUpdate(parameter, value, readerror, timestamp)
|
||||
|
||||
def initModule(self):
|
||||
if not self.module:
|
||||
self.module = self.name
|
||||
self._secnode = self.io.secnode
|
||||
self._secnode.register_callback(self.module, self.updateEvent,
|
||||
self.descriptiveDataChange, self.nodeStateChange)
|
||||
super().initModule()
|
||||
|
||||
def descriptiveDataChange(self, module, moddesc):
|
||||
if module is None:
|
||||
return # do not care about the node for now
|
||||
self._check_descriptive_data()
|
||||
|
||||
def _check_descriptive_data(self):
|
||||
params = self.parameters.copy()
|
||||
cmds = self.commands.copy()
|
||||
moddesc = self._secnode.modules[self.module]
|
||||
remoteparams = moddesc['parameters'].copy()
|
||||
remotecmds = moddesc['commands'].copy()
|
||||
while params:
|
||||
pname, pobj = params.popitem()
|
||||
props = remoteparams.get(pname, None)
|
||||
if props is None:
|
||||
self.log.warning('remote parameter %s:%s does not exist' % (self.module, pname))
|
||||
continue
|
||||
dt = props['datatype']
|
||||
try:
|
||||
if pobj.readonly:
|
||||
dt.compatible(pobj.datatype)
|
||||
else:
|
||||
if props['readonly']:
|
||||
self.log.warning('remote parameter %s:%s is read only' % (self.module, pname))
|
||||
pobj.datatype.compatible(dt)
|
||||
try:
|
||||
dt.compatible(pobj.datatype)
|
||||
except Exception:
|
||||
self.log.warning('remote parameter %s:%s is not fully compatible: %r != %r'
|
||||
% (self.module, pname, pobj.datatype, dt))
|
||||
except Exception:
|
||||
self.log.warning('remote parameter %s:%s has an incompatible datatype: %r != %r'
|
||||
% (self.module, pname, pobj.datatype, dt))
|
||||
while cmds:
|
||||
cname, cobj = cmds.popitem()
|
||||
props = remotecmds.get(cname)
|
||||
if props is None:
|
||||
self.log.warning('remote command %s:%s does not exist' % (self.module, cname))
|
||||
continue
|
||||
dt = props['datatype']
|
||||
try:
|
||||
cobj.datatype.compatible(dt)
|
||||
except BadValueError:
|
||||
self.log.warning('remote command %s:%s is not compatible: %r != %r'
|
||||
% (self.module, cname, cobj.datatype, dt))
|
||||
# what to do if descriptive data does not match?
|
||||
# we might raise an exception, but this would lead to a reconnection,
|
||||
# which might not help.
|
||||
# for now, the error message must be enough
|
||||
|
||||
def nodeStateChange(self, online, state):
|
||||
if online:
|
||||
if not self._consistency_check_done:
|
||||
self._check_descriptive_data()
|
||||
self._consistency_check_done = True
|
||||
else:
|
||||
newstatus = Readable.Status.ERROR, 'disconnected'
|
||||
readerror = CommunicationFailedError('disconnected')
|
||||
if self.status != newstatus:
|
||||
for pname in set(self.parameters) - set(('module', 'status')):
|
||||
self.announceUpdate(pname, None, readerror)
|
||||
self.announceUpdate('status', newstatus)
|
||||
|
||||
def checkProperties(self):
|
||||
pass # skip
|
||||
|
||||
|
||||
class ProxyReadable(ProxyModule, Readable):
|
||||
pass
|
||||
|
||||
|
||||
class ProxyWritable(ProxyModule, Writable):
|
||||
pass
|
||||
|
||||
|
||||
class ProxyDrivable(ProxyModule, Drivable):
|
||||
pass
|
||||
|
||||
|
||||
PROXY_CLASSES = [ProxyDrivable, ProxyWritable, ProxyReadable, ProxyModule]
|
||||
|
||||
|
||||
class SecNode(Module):
|
||||
uri = Property('uri of a SEC node', datatype=StringType())
|
||||
|
||||
def earlyInit(self):
|
||||
super().earlyInit()
|
||||
self.secnode = SecopClient(self.uri, self.log)
|
||||
|
||||
def startModule(self, start_events):
|
||||
super().startModule(start_events)
|
||||
self.secnode.spawn_connect(start_events.get_trigger())
|
||||
|
||||
@Command(StringType(), result=StringType())
|
||||
def request(self, msg):
|
||||
"""send a request, for debugging purposes"""
|
||||
reply = self.secnode.request(*decode_msg(msg.encode('utf-8')))
|
||||
# pylint: disable=not-an-iterable
|
||||
return encode_msg_frame(*reply).decode('utf-8')
|
||||
|
||||
|
||||
def proxy_class(remote_class, name=None):
|
||||
"""create a proxy class based on the definition of remote class
|
||||
|
||||
remote class is <import path>.<class name> of a class used on the remote node
|
||||
if name is not given, 'Proxy' + <class name> is used
|
||||
"""
|
||||
if isinstance(remote_class, type) and issubclass(remote_class, Module):
|
||||
rcls = remote_class
|
||||
remote_class = rcls.__name__
|
||||
else:
|
||||
rcls = get_class(remote_class)
|
||||
if name is None:
|
||||
name = rcls.__name__
|
||||
|
||||
for proxycls in PROXY_CLASSES:
|
||||
if issubclass(rcls, proxycls.__bases__[-1]):
|
||||
# avoid 'should not be redefined' warning
|
||||
proxycls.accessibles = {}
|
||||
break
|
||||
else:
|
||||
raise ConfigError('%r is no SECoP module class' % remote_class)
|
||||
|
||||
attrs = rcls.propertyDict.copy()
|
||||
|
||||
for aname, aobj in rcls.accessibles.items():
|
||||
if isinstance(aobj, Parameter):
|
||||
pobj = aobj.copy()
|
||||
pobj.merge(dict(needscfg=False))
|
||||
attrs[aname] = pobj
|
||||
|
||||
def rfunc(self, pname=aname):
|
||||
value, _, readerror = self._secnode.getParameter(self.name, pname, True)
|
||||
if readerror:
|
||||
raise readerror
|
||||
return value
|
||||
|
||||
attrs['read_' + aname] = rfunc
|
||||
|
||||
if not pobj.readonly:
|
||||
|
||||
def wfunc(self, value, pname=aname):
|
||||
value, _, readerror = self._secnode.setParameter(self.name, pname, value)
|
||||
if readerror:
|
||||
raise readerror
|
||||
return value
|
||||
|
||||
attrs['write_' + aname] = wfunc
|
||||
|
||||
elif isinstance(aobj, Command):
|
||||
cobj = aobj.copy()
|
||||
|
||||
def cfunc(self, arg=None, cname=aname):
|
||||
return self._secnode.execCommand(self.name, cname, arg)
|
||||
|
||||
attrs[aname] = cobj(cfunc)
|
||||
|
||||
else:
|
||||
raise ConfigError('do not now about %r in %s.accessibles' % (aobj, remote_class))
|
||||
|
||||
return type(name, (proxycls,), attrs)
|
||||
|
||||
|
||||
def Proxy(name, logger, cfgdict, srv):
|
||||
"""create a Proxy object based on remote_class
|
||||
|
||||
title cased as it acts like a class
|
||||
"""
|
||||
remote_class = cfgdict.pop('remote_class')
|
||||
if 'description' not in cfgdict:
|
||||
cfgdict['description'] = 'remote module %s on %s' % (
|
||||
cfgdict.get('module', name), cfgdict.get('io', '?'))
|
||||
return proxy_class(remote_class)(name, logger, cfgdict, srv)
|
||||
221
frappy/rwhandler.py
Normal file
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
# 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:
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
# *****************************************************************************
|
||||
|
||||
"""decorator class for common read_/write_ methods
|
||||
|
||||
Usage:
|
||||
|
||||
Example 1: combined read/write for multiple parameters
|
||||
|
||||
PID_PARAMS = ['p', 'i', 'd']
|
||||
|
||||
@CommonReadHandler(PID_PARAMS)
|
||||
def read_pid(self):
|
||||
self.p, self.i, self.d = self.get_pid_from_hw()
|
||||
# no return value
|
||||
|
||||
@CommonWriteHandler(PID_PARAMS)
|
||||
def write_pid(self, values):
|
||||
# values is a dict[pname] of value, we convert it to a tuple here
|
||||
self.put_pid_to_hw(values.as_tuple('p', 'i', 'd'')) # or .as_tuple(*PID_PARAMS)
|
||||
self.read_pid()
|
||||
# no return value
|
||||
|
||||
Example 2: addressable HW parameters
|
||||
|
||||
HW_ADDR = {'p': 25, 'i': 26, 'd': 27}
|
||||
|
||||
@ReadHandler(HW_ADDR)
|
||||
def read_addressed(self, pname):
|
||||
return self.get_hw_register(HW_ADDR[pname])
|
||||
|
||||
@WriteHandler(HW_ADDR)
|
||||
def write_addressed(self, pname, value):
|
||||
self.put_hw_register(HW_ADDR[pname], value)
|
||||
return self.get_hw_register(HW_ADDR[pname])
|
||||
"""
|
||||
|
||||
import functools
|
||||
from frappy.modules import Done
|
||||
from frappy.errors import ProgrammingError
|
||||
|
||||
|
||||
def wraps(func):
|
||||
"""decorator to copy function attributes of wrapped function"""
|
||||
# we modify the default here:
|
||||
# copy __doc__ , __module___ and attributes from __dict__
|
||||
# but not __name__ and __qualname__
|
||||
return functools.wraps(func, assigned=('__doc__', '__module__'))
|
||||
|
||||
|
||||
class Handler:
|
||||
func = None
|
||||
method_names = set() # this is shared among all instances of handlers!
|
||||
wrapped = True # allow to use read_* or write_* as name of the decorated method
|
||||
prefix = None # 'read_' or 'write_'
|
||||
poll = None
|
||||
|
||||
def __init__(self, keys):
|
||||
"""initialize the decorator
|
||||
|
||||
:param keys: parameter names (an iterable)
|
||||
"""
|
||||
self.keys = set(keys)
|
||||
|
||||
def __call__(self, func):
|
||||
"""decorator call"""
|
||||
self.func = func
|
||||
if func.__qualname__ in self.method_names:
|
||||
raise ProgrammingError('duplicate method %r' % func.__qualname__)
|
||||
func.wrapped = False
|
||||
# __qualname__ used here (avoid conflicts between different modules)
|
||||
self.method_names.add(func.__qualname__)
|
||||
return self
|
||||
|
||||
def __get__(self, obj, owner=None):
|
||||
"""allow access to the common method"""
|
||||
if obj is None:
|
||||
return self
|
||||
return self.func.__get__(obj, owner)
|
||||
|
||||
def __set_name__(self, owner, name):
|
||||
"""create the wrapped read_* or write_* methods"""
|
||||
|
||||
self.method_names.discard(self.func.__qualname__)
|
||||
for key in self.keys:
|
||||
wrapped = self.wrap(key)
|
||||
method_name = self.prefix + key
|
||||
wrapped.wrapped = True
|
||||
if self.poll is not None:
|
||||
# wrapped.poll is False when the nopoll decorator is applied either to self.func or to self
|
||||
wrapped.poll = getattr(wrapped, 'poll', self.poll)
|
||||
func = getattr(owner, method_name, None)
|
||||
if func and not func.wrapped:
|
||||
raise ProgrammingError('superfluous method %s.%s (overwritten by %s)'
|
||||
% (owner.__name__, method_name, self.__class__.__name__))
|
||||
setattr(owner, method_name, wrapped)
|
||||
|
||||
def wrap(self, key):
|
||||
"""create wrapped method from self.func
|
||||
|
||||
with name self.prefix + key"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ReadHandler(Handler):
|
||||
"""decorator for read handler methods"""
|
||||
prefix = 'read_'
|
||||
poll = True
|
||||
|
||||
def wrap(self, key):
|
||||
def method(module, pname=key, func=self.func):
|
||||
with module.accessLock:
|
||||
value = func(module, pname)
|
||||
if value is Done:
|
||||
return getattr(module, pname)
|
||||
setattr(module, pname, value)
|
||||
return value
|
||||
|
||||
return wraps(self.func)(method)
|
||||
|
||||
|
||||
class CommonReadHandler(ReadHandler):
|
||||
"""decorator for a handler reading several parameters in one go"""
|
||||
def __init__(self, keys):
|
||||
"""initialize the decorator
|
||||
|
||||
:param keys: parameter names (an iterable)
|
||||
"""
|
||||
super().__init__(keys)
|
||||
self.first_key = next(iter(keys))
|
||||
|
||||
def wrap(self, key):
|
||||
def method(module, pname=key, func=self.func):
|
||||
with module.accessLock:
|
||||
ret = func(module)
|
||||
if ret not in (None, Done):
|
||||
raise ProgrammingError('a method wrapped with CommonReadHandler must not return any value')
|
||||
return getattr(module, pname)
|
||||
|
||||
method = wraps(self.func)(method)
|
||||
method.poll = self.poll and getattr(method, 'poll', True) if key == self.first_key else False
|
||||
return method
|
||||
|
||||
|
||||
class WriteHandler(Handler):
|
||||
"""decorator for write handler methods"""
|
||||
prefix = 'write_'
|
||||
|
||||
def wrap(self, key):
|
||||
@wraps(self.func)
|
||||
def method(module, value, pname=key, func=self.func):
|
||||
with module.accessLock:
|
||||
value = func(module, pname, value)
|
||||
if value is not Done:
|
||||
setattr(module, pname, value)
|
||||
return value
|
||||
return method
|
||||
|
||||
|
||||
class WriteParameters(dict):
|
||||
def __init__(self, modobj):
|
||||
super().__init__()
|
||||
self.obj = modobj
|
||||
|
||||
def __missing__(self, key):
|
||||
try:
|
||||
return self.obj.writeDict.pop(key)
|
||||
except KeyError:
|
||||
return getattr(self.obj, key)
|
||||
|
||||
def as_tuple(self, *keys):
|
||||
"""return values of given keys as a tuple"""
|
||||
return tuple(self[k] for k in keys)
|
||||
|
||||
|
||||
class CommonWriteHandler(WriteHandler):
|
||||
"""decorator for common write handler
|
||||
|
||||
calls the wrapped write method function with values as an argument.
|
||||
- values[pname] returns the to be written value
|
||||
- values['key'] returns a value taken from writeDict
|
||||
or, if not available return obj.key
|
||||
- values.as_tuple() returns a tuple with the items in the same order as keys
|
||||
"""
|
||||
|
||||
def wrap(self, key):
|
||||
@wraps(self.func)
|
||||
def method(module, value, pname=key, func=self.func):
|
||||
with module.accessLock:
|
||||
values = WriteParameters(module)
|
||||
values[pname] = value
|
||||
ret = func(module, values)
|
||||
if ret not in (None, Done):
|
||||
raise ProgrammingError('a method wrapped with CommonWriteHandler must not return any value')
|
||||
# remove pname from writeDict. this was not removed in WriteParameters, as it was not missing
|
||||
module.writeDict.pop(pname, None)
|
||||
return method
|
||||
|
||||
|
||||
def nopoll(func):
|
||||
"""decorator to indicate that a read method is not to be polled"""
|
||||
func.poll = False
|
||||
return func
|
||||
356
frappy/server.py
Normal file
@@ -0,0 +1,356 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
# Alexander Lenz <alexander.lenz@frm2.tum.de>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define helpers"""
|
||||
|
||||
import ast
|
||||
import configparser
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from collections import OrderedDict
|
||||
|
||||
from frappy.errors import ConfigError, SECoPError
|
||||
from frappy.lib import formatException, get_class, generalConfig
|
||||
from frappy.lib.multievent import MultiEvent
|
||||
from frappy.params import PREDEFINED_ACCESSIBLES
|
||||
from frappy.modules import Attached
|
||||
|
||||
try:
|
||||
from daemon import DaemonContext
|
||||
try:
|
||||
from daemon import pidlockfile
|
||||
except ImportError:
|
||||
import daemon.pidfile as pidlockfile
|
||||
except ImportError:
|
||||
DaemonContext = None
|
||||
|
||||
try:
|
||||
import systemd.daemon
|
||||
except ImportError:
|
||||
systemd = None
|
||||
|
||||
|
||||
class Server:
|
||||
INTERFACES = {
|
||||
'tcp': 'protocol.interface.tcp.TCPServer',
|
||||
}
|
||||
_restart = True
|
||||
|
||||
def __init__(self, name, parent_logger, cfgfiles=None, interface=None, testonly=False):
|
||||
"""initialize server
|
||||
|
||||
Arguments:
|
||||
- name: the node name
|
||||
- parent_logger: the logger to inherit from
|
||||
- cfgfiles: if not given, defaults to name
|
||||
may be a comma separated list of cfg files
|
||||
items ending with .cfg are taken as paths, else .cfg is appended and
|
||||
files are looked up in the config path retrieved from the general config
|
||||
- interface: an uri of the from tcp://<port> or a bare port number for tcp
|
||||
if not given, the interface is taken from the config file. In case of
|
||||
multiple cfg files, the interface is taken from the first cfg file
|
||||
- testonly: test mode. tries to build all modules, but the server is not started
|
||||
|
||||
Format of cfg file (for now, both forms are accepted):
|
||||
old form: new form:
|
||||
|
||||
[node <equipment id>] [NODE]
|
||||
description=<descr> id=<equipment id>
|
||||
description=<descr>
|
||||
|
||||
[interface tcp] [INTERFACE]
|
||||
bindport=10769 uri=tcp://10769
|
||||
bindto=0.0.0.0
|
||||
|
||||
[module temp] [temp]
|
||||
ramp=12 ramp=12
|
||||
...
|
||||
"""
|
||||
self._testonly = testonly
|
||||
|
||||
if not cfgfiles:
|
||||
cfgfiles = name
|
||||
# sanitize name (in case it is a cfgfile)
|
||||
name = os.path.splitext(os.path.basename(name))[0]
|
||||
self.log = parent_logger.getChild(name, True)
|
||||
merged_cfg = OrderedDict()
|
||||
ambiguous_sections = set()
|
||||
for cfgfile in cfgfiles.split(','):
|
||||
cfgdict = self.loadCfgFile(cfgfile)
|
||||
ambiguous_sections |= set(merged_cfg) & set(cfgdict)
|
||||
merged_cfg.update(cfgdict)
|
||||
self.node_cfg = merged_cfg.pop('NODE', {})
|
||||
self.interface_cfg = merged_cfg.pop('INTERFACE', {})
|
||||
self.module_cfg = merged_cfg
|
||||
if interface:
|
||||
ambiguous_sections.discard('interface')
|
||||
ambiguous_sections.discard('node')
|
||||
self.node_cfg['name'] = name
|
||||
self.node_cfg['id'] = cfgfiles
|
||||
self.interface_cfg['uri'] = str(interface)
|
||||
elif 'uri' not in self.interface_cfg:
|
||||
raise ConfigError('missing interface uri')
|
||||
if ambiguous_sections:
|
||||
self.log.warning('ambiguous sections in %s: %r' % (cfgfiles, tuple(ambiguous_sections)))
|
||||
self._cfgfiles = cfgfiles
|
||||
self._pidfile = os.path.join(generalConfig.piddir, name + '.pid')
|
||||
|
||||
def loadCfgFile(self, cfgfile):
|
||||
if not cfgfile.endswith('.cfg'):
|
||||
cfgfile += '.cfg'
|
||||
if os.sep in cfgfile: # specified as full path
|
||||
filename = cfgfile if os.path.exists(cfgfile) else None
|
||||
else:
|
||||
for filename in [os.path.join(d, cfgfile) for d in generalConfig.confdir.split(os.pathsep)]:
|
||||
if os.path.exists(filename):
|
||||
break
|
||||
else:
|
||||
filename = None
|
||||
if filename is None:
|
||||
raise ConfigError("Couldn't find cfg file %r in %s" % (cfgfile, generalConfig.confdir))
|
||||
self.log.debug('Parse config file %s ...' % filename)
|
||||
result = OrderedDict()
|
||||
parser = configparser.ConfigParser()
|
||||
parser.optionxform = str
|
||||
if not parser.read([filename]):
|
||||
raise ConfigError("Couldn't read cfg file %r" % filename)
|
||||
for section, options in parser.items():
|
||||
if section == 'DEFAULT':
|
||||
continue
|
||||
opts = {}
|
||||
for k, v in options.items():
|
||||
# is the following really needed? - ConfigParser supports multiple lines!
|
||||
while '\n.\n' in v:
|
||||
v = v.replace('\n.\n', '\n\n')
|
||||
try:
|
||||
opts[k] = ast.literal_eval(v)
|
||||
except Exception:
|
||||
opts[k] = v
|
||||
# convert old form
|
||||
name, _, arg = section.partition(' ')
|
||||
if arg:
|
||||
if name == 'node':
|
||||
name = 'NODE'
|
||||
opts['id'] = arg
|
||||
elif name == 'interface':
|
||||
name = 'INTERFACE'
|
||||
if 'bindport' in opts:
|
||||
opts.pop('bindto', None)
|
||||
opts['uri'] = '%s://%s' % (opts.pop('type', arg), opts.pop('bindport'))
|
||||
elif name == 'module':
|
||||
name = arg
|
||||
result[name] = opts
|
||||
return result
|
||||
|
||||
def start(self):
|
||||
if not DaemonContext:
|
||||
raise ConfigError('can not daemonize, as python-daemon is not installed')
|
||||
piddir = os.path.dirname(self._pidfile)
|
||||
if not os.path.isdir(piddir):
|
||||
os.makedirs(piddir)
|
||||
pidfile = pidlockfile.TimeoutPIDLockFile(self._pidfile)
|
||||
|
||||
if pidfile.is_locked():
|
||||
self.log.error('Pidfile already exists. Exiting')
|
||||
|
||||
with DaemonContext(
|
||||
pidfile=pidfile,
|
||||
files_preserve=self.log.getLogfileStreams()):
|
||||
self.run()
|
||||
|
||||
def unknown_options(self, cls, options):
|
||||
return ("%s class don't know how to handle option(s): %s" %
|
||||
(cls.__name__, ', '.join(options)))
|
||||
|
||||
def run(self):
|
||||
while self._restart:
|
||||
self._restart = False
|
||||
try:
|
||||
if systemd:
|
||||
systemd.daemon.notify("STATUS=initializing")
|
||||
self._processCfg()
|
||||
if self._testonly:
|
||||
return
|
||||
except Exception:
|
||||
print(formatException(verbose=True))
|
||||
raise
|
||||
|
||||
opts = dict(self.interface_cfg)
|
||||
scheme, _, _ = opts['uri'].rpartition('://')
|
||||
scheme = scheme or 'tcp'
|
||||
cls = get_class(self.INTERFACES[scheme])
|
||||
with cls(scheme, self.log.getChild(scheme), opts, self) as self.interface:
|
||||
if opts:
|
||||
raise ConfigError(self.unknown_options(cls, opts))
|
||||
self.log.info('startup done, handling transport messages')
|
||||
if systemd:
|
||||
systemd.daemon.notify("READY=1\nSTATUS=accepting requests")
|
||||
self.interface.serve_forever()
|
||||
self.interface.server_close()
|
||||
if self._restart:
|
||||
self.restart_hook()
|
||||
self.log.info('restart')
|
||||
else:
|
||||
self.log.info('shut down')
|
||||
|
||||
def restart(self):
|
||||
if not self._restart:
|
||||
self._restart = True
|
||||
self.interface.shutdown()
|
||||
|
||||
def shutdown(self):
|
||||
self._restart = False
|
||||
self.interface.shutdown()
|
||||
|
||||
def _processCfg(self):
|
||||
errors = []
|
||||
opts = dict(self.node_cfg)
|
||||
cls = get_class(opts.pop('class', 'protocol.dispatcher.Dispatcher'))
|
||||
self.dispatcher = cls(opts.pop('name', self._cfgfiles), self.log.getChild('dispatcher'), opts, self)
|
||||
if opts:
|
||||
errors.append(self.unknown_options(cls, opts))
|
||||
self.modules = OrderedDict()
|
||||
failure_traceback = None # traceback for the first error
|
||||
failed = set() # python modules failed to load
|
||||
self.lastError = None
|
||||
for modname, options in self.module_cfg.items():
|
||||
opts = dict(options)
|
||||
pymodule = None
|
||||
try:
|
||||
classname = opts.pop('class')
|
||||
pymodule = classname.rpartition('.')[0]
|
||||
if pymodule in failed:
|
||||
continue
|
||||
cls = get_class(classname)
|
||||
except Exception as e:
|
||||
if str(e) == 'no such class':
|
||||
errors.append('%s not found' % classname)
|
||||
else:
|
||||
failed.add(pymodule)
|
||||
if failure_traceback is None:
|
||||
failure_traceback = traceback.format_exc()
|
||||
errors.append('error importing %s' % classname)
|
||||
else:
|
||||
try:
|
||||
modobj = cls(modname, self.log.getChild(modname), opts, self)
|
||||
# all used args should be popped from opts!
|
||||
if opts:
|
||||
errors.append(self.unknown_options(cls, opts))
|
||||
self.modules[modname] = modobj
|
||||
except ConfigError as e:
|
||||
errors.append('error creating module %s:' % modname)
|
||||
for errtxt in e.args[0] if isinstance(e.args[0], list) else [e.args[0]]:
|
||||
errors.append(' ' + errtxt)
|
||||
except Exception:
|
||||
if failure_traceback is None:
|
||||
failure_traceback = traceback.format_exc()
|
||||
errors.append('error creating %s' % modname)
|
||||
|
||||
missing_super = set()
|
||||
# all objs created, now start them up and interconnect
|
||||
for modname, modobj in self.modules.items():
|
||||
self.log.info('registering module %r' % modname)
|
||||
self.dispatcher.register_module(modobj, modname, modobj.export)
|
||||
# also call earlyInit on the modules
|
||||
modobj.earlyInit()
|
||||
if not modobj.earlyInitDone:
|
||||
missing_super.add('%s was not called, probably missing super call'
|
||||
% modobj.earlyInit.__qualname__)
|
||||
|
||||
# handle attached modules
|
||||
for modname, modobj in self.modules.items():
|
||||
attached_modules = {}
|
||||
for propname, propobj in modobj.propertyDict.items():
|
||||
if isinstance(propobj, Attached):
|
||||
try:
|
||||
attname = getattr(modobj, propname)
|
||||
if attname: # attached module specified in cfg file
|
||||
attobj = self.dispatcher.get_module(attname)
|
||||
if isinstance(attobj, propobj.basecls):
|
||||
attached_modules[propname] = attobj
|
||||
else:
|
||||
errors.append('attached module %s=%r must inherit from %r'
|
||||
% (propname, attname, propobj.basecls.__qualname__))
|
||||
except SECoPError as e:
|
||||
errors.append('module %s, attached %s: %s' % (modname, propname, str(e)))
|
||||
modobj.attachedModules = attached_modules
|
||||
|
||||
# call init on each module after registering all
|
||||
for modname, modobj in self.modules.items():
|
||||
try:
|
||||
modobj.initModule()
|
||||
if not modobj.initModuleDone:
|
||||
missing_super.add('%s was not called, probably missing super call'
|
||||
% modobj.initModule.__qualname__)
|
||||
except Exception as e:
|
||||
if failure_traceback is None:
|
||||
failure_traceback = traceback.format_exc()
|
||||
errors.append('error initializing %s: %r' % (modname, e))
|
||||
|
||||
if not self._testonly:
|
||||
start_events = MultiEvent(default_timeout=30)
|
||||
for modname, modobj in self.modules.items():
|
||||
# startModule must return either a timeout value or None (default 30 sec)
|
||||
start_events.name = 'module %s' % modname
|
||||
modobj.startModule(start_events)
|
||||
if not modobj.startModuleDone:
|
||||
missing_super.add('%s was not called, probably missing super call'
|
||||
% modobj.startModule.__qualname__)
|
||||
errors.extend(missing_super)
|
||||
|
||||
if errors:
|
||||
for errtxt in errors:
|
||||
for line in errtxt.split('\n'):
|
||||
self.log.error(line)
|
||||
# print a list of config errors to stderr
|
||||
sys.stderr.write('\n'.join(errors))
|
||||
sys.stderr.write('\n')
|
||||
if failure_traceback:
|
||||
sys.stderr.write(failure_traceback)
|
||||
sys.exit(1)
|
||||
|
||||
if self._testonly:
|
||||
return
|
||||
self.log.info('waiting for modules being started')
|
||||
start_events.name = None
|
||||
if not start_events.wait():
|
||||
# some timeout happened
|
||||
for name in start_events.waiting_for():
|
||||
self.log.warning('timeout when starting %s' % name)
|
||||
self.log.info('all modules started')
|
||||
history_path = os.environ.get('FRAPPY_HISTORY')
|
||||
if history_path:
|
||||
from frappy_psi.historywriter import FrappyHistoryWriter # pylint: disable=import-outside-toplevel
|
||||
writer = FrappyHistoryWriter(history_path, PREDEFINED_ACCESSIBLES.keys(), self.dispatcher)
|
||||
# treat writer as a connection
|
||||
self.dispatcher.add_connection(writer)
|
||||
writer.init(self.dispatcher.handle_describe(writer, None, None))
|
||||
# TODO: if ever somebody wants to implement an other history writer:
|
||||
# - a general config file /etc/secp/frappy.conf or <frappy repo>/etc/frappy.conf
|
||||
# might be introduced, which contains the log, pid and cfg directory path and
|
||||
# the class path implementing the history
|
||||
# - or we just add here an other if statement:
|
||||
# history_path = os.environ.get('ALTERNATIVE_HISTORY')
|
||||
# if history_path:
|
||||
# from frappy_<xx>.historywriter import ... etc.
|
||||
145
frappy/simulation.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
#
|
||||
# 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:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Define Simulation classes"""
|
||||
|
||||
|
||||
import random
|
||||
from time import sleep
|
||||
|
||||
from frappy.datatypes import FloatRange
|
||||
from frappy.lib import mkthread
|
||||
from frappy.modules import Drivable, Module, Parameter, Readable, Writable, Command
|
||||
|
||||
|
||||
class SimBase:
|
||||
def __new__(cls, devname, logger, cfgdict, dispatcher):
|
||||
extra_params = cfgdict.pop('extra_params', '') or cfgdict.pop('.extra_params', '')
|
||||
attrs = {}
|
||||
if extra_params:
|
||||
for k in extra_params.split(','):
|
||||
k = k.strip()
|
||||
attrs[k] = Parameter('extra_param: %s' % k.strip(),
|
||||
datatype=FloatRange(),
|
||||
default=0.0)
|
||||
|
||||
def reader(self, pname=k):
|
||||
self.log.debug('simulated reading %s' % pname)
|
||||
return self.parameters[pname].value
|
||||
|
||||
attrs['read_' + k] = reader
|
||||
|
||||
def writer(self, newval, pname=k):
|
||||
self.log.debug('simulated writing %r to %s' % (newval, pname))
|
||||
self.parameters[pname].value = newval
|
||||
return newval
|
||||
|
||||
attrs['write_' + k] = writer
|
||||
|
||||
return object.__new__(type('SimBase_%s' % devname, (cls,), attrs))
|
||||
|
||||
def initModule(self):
|
||||
super().initModule()
|
||||
self._sim_thread = mkthread(self._sim)
|
||||
|
||||
def _sim(self):
|
||||
try:
|
||||
if not self.sim():
|
||||
self.log.info('sim thread running')
|
||||
while not self.sim():
|
||||
pass
|
||||
self.log.info('sim thread ended')
|
||||
except Exception as e:
|
||||
self.log.exception(e)
|
||||
|
||||
def sim(self):
|
||||
return True # nothing to do, stop thread
|
||||
|
||||
|
||||
class SimModule(SimBase, Module):
|
||||
pass
|
||||
|
||||
|
||||
class SimReadable(SimBase, Readable):
|
||||
def __init__(self, devname, logger, cfgdict, dispatcher):
|
||||
super().__init__(devname, logger, cfgdict, dispatcher)
|
||||
self._value = self.parameters['value'].default
|
||||
|
||||
def read_value(self):
|
||||
if 'jitter' in self.parameters:
|
||||
return self._value + self.jitter * (0.5 - random.random())
|
||||
return self._value
|
||||
|
||||
|
||||
class SimWritable(SimReadable, Writable):
|
||||
|
||||
def read_value(self):
|
||||
return self.target
|
||||
|
||||
def write_target(self, value):
|
||||
self.value = value
|
||||
|
||||
def _hw_wait(self):
|
||||
pass
|
||||
|
||||
|
||||
class SimDrivable(SimReadable, Drivable):
|
||||
interval = Parameter('simulation interval', FloatRange(0, 1), readonly=False, default=0.3)
|
||||
|
||||
def sim(self):
|
||||
while self._value == self.target:
|
||||
sleep(self.interval)
|
||||
self.status = self.Status.BUSY, 'MOVING'
|
||||
speed = 0
|
||||
if 'ramp' in self.accessibles:
|
||||
speed = self.ramp / 60. # ramp is per minute!
|
||||
elif 'speed' in self.accessibles:
|
||||
speed = self.speed
|
||||
if speed == 0:
|
||||
self._value = self.target
|
||||
speed *= self.interval
|
||||
try:
|
||||
self.doPoll()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while self._value != self.target:
|
||||
if self._value < self.target - speed:
|
||||
self._value += speed
|
||||
elif self._value > self.target + speed:
|
||||
self._value -= speed
|
||||
else:
|
||||
self._value = self.target
|
||||
sleep(self.interval)
|
||||
try:
|
||||
self.doPoll()
|
||||
except Exception:
|
||||
pass
|
||||
self.status = self.Status.IDLE, ''
|
||||
return False # keep thread running
|
||||
|
||||
def _hw_wait(self):
|
||||
while self.status[0] == self.Status.BUSY:
|
||||
sleep(self.interval)
|
||||
|
||||
@Command
|
||||
def stop(self):
|
||||
self.target = self.value
|
||||
87
frappy/version.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# *****************************************************************************
|
||||
# Copyright (c) 2015-2019 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:
|
||||
# Douglas Creager <dcreager@dcreager.net>
|
||||
# This file is placed into the public domain.
|
||||
# fixes for PEP440 by:
|
||||
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
import os.path
|
||||
from subprocess import PIPE, Popen
|
||||
|
||||
__all__ = ['get_version']
|
||||
|
||||
RELEASE_VERSION_FILE = os.path.join(os.path.dirname(__file__),
|
||||
'RELEASE-VERSION')
|
||||
GIT_REPO = os.path.join(os.path.dirname(__file__), '..', '.git')
|
||||
|
||||
|
||||
def get_git_version(abbrev=4, cwd=None):
|
||||
try:
|
||||
print("REPO:", GIT_REPO)
|
||||
with Popen(['git', '--git-dir=%s' % GIT_REPO,
|
||||
'describe', '--abbrev=%d' % abbrev],
|
||||
stdout=PIPE, stderr=PIPE) as p:
|
||||
stdout, _stderr = p.communicate()
|
||||
version = stdout.strip().decode('utf-8', 'ignore')
|
||||
print("git:", version)
|
||||
# mangle version to comply with pep440
|
||||
if version.count('-'):
|
||||
version, patchcount, githash = version.split('-')
|
||||
version += '.post%s+%s' % (patchcount, githash)
|
||||
return version
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def read_release_version():
|
||||
try:
|
||||
with open(RELEASE_VERSION_FILE, encoding='utf-8') as f:
|
||||
return f.readline().strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_release_version(version):
|
||||
with open(RELEASE_VERSION_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write("%s\n" % version)
|
||||
|
||||
|
||||
def get_version(abbrev=4):
|
||||
# determine the version from git and from RELEASE-VERSION
|
||||
git_version = get_git_version(abbrev)
|
||||
release_version = read_release_version()
|
||||
|
||||
# if we have a git version, it is authoritative
|
||||
if git_version:
|
||||
if git_version != release_version:
|
||||
write_release_version(git_version)
|
||||
return git_version
|
||||
if release_version:
|
||||
return release_version
|
||||
raise ValueError('Cannot find a version number - make sure that '
|
||||
'git is installed or a RELEASE-VERSION file is '
|
||||
'present!')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(get_version())
|
||||