Rename from secop to frappy

debian/ is still missing, will follow in next commit.

Fixes: #4626

Change-Id: Ia87c28c1c75b8402eedbfca47f888585a7881f44
This commit is contained in:
Alexander Zaft
2022-11-08 08:09:41 +01:00
committed by Enrico Faulhaber
parent c1eb764b09
commit 7f166a5b8c
168 changed files with 558 additions and 554 deletions

24
frappy/__init__.py Normal file
View 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
View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

155
frappy/errors.py Normal file
View 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
View 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
View File

View File

View 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:])

View 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)

View 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())

View 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)

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View 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>&amp;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>&amp;Help</string>
</property>
<addaction name="actionAbout"/>
</widget>
<widget class="QMenu" name="menu_Edit">
<property name="title">
<string>&amp;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>&amp;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>&amp;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>&amp;Save</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
<action name="actionSave_as">
<property name="text">
<string>Save &amp;as ...</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+S</string>
</property>
</action>
<action name="actionAbout">
<property name="text">
<string>&amp;About</string>
</property>
<property name="shortcut">
<string>Ctrl+A</string>
</property>
</action>
<action name="actionQuit">
<property name="text">
<string>&amp;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>&amp;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>

View 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>

View 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

View 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

File diff suppressed because it is too large Load Diff

5960
frappy/gui/icon_rc_qt5.py Normal file

File diff suppressed because it is too large Load Diff

238
frappy/gui/mainwindow.py Normal file
View 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
View 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
View 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
View 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)

View 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
View 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
View 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)

View 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>

View 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
View 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>

View 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
View 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
View 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>&lt;!DOCTYPE HTML PUBLIC &quot;-//W3C//DTD HTML 4.0//EN&quot; &quot;http://www.w3.org/TR/REC-html40/strict.dtd&quot;&gt;
&lt;html&gt;&lt;head&gt;&lt;meta name=&quot;qrichtext&quot; content=&quot;1&quot; /&gt;&lt;style type=&quot;text/css&quot;&gt;
p, li { white-space: pre-wrap; }
&lt;/style&gt;&lt;/head&gt;&lt;body style=&quot; font-family:'Noto Sans'; font-size:12pt; font-weight:400; font-style:normal;&quot;&gt;
&lt;p style=&quot;-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;&quot;&gt;&lt;br /&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</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>&gt;&gt;&gt;</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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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"""

View 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

View 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)

View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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())