frappy/secop/modules.py
Enrico Faulhaber 927ca854a2 rework polling
Readable/Moveable can't be busy....

Change-Id: Icfe250e16dd30646cf3081f4850a8bacaa77a935
Reviewed-on: https://forge.frm2.tum.de/review/17859
Tested-by: JenkinsCodeReview <bjoern_pedersen@frm2.tum.de>
Reviewed-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
2018-04-26 16:43:55 +02:00

574 lines
21 KiB
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:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
#
# *****************************************************************************
"""Define Baseclasses for real Modules implemented in the server"""
from __future__ import print_function
# XXX: connect with 'protocol'-Modules.
# Idea: every Module defined herein is also a 'protocol'-Module,
# all others MUST derive from those, the 'interface'-class is still derived
# from these base classes (how to do this?)
import time
import types
import inspect
try:
from six import add_metaclass # for py2/3 compat
except ImportError:
# copied from six v1.10.0
def add_metaclass(metaclass):
"""Class decorator for creating a class with a metaclass."""
def wrapper(cls):
orig_vars = cls.__dict__.copy()
slots = orig_vars.get('__slots__')
if slots is not None:
if isinstance(slots, str):
slots = [slots]
for slots_var in slots:
orig_vars.pop(slots_var)
orig_vars.pop('__dict__', None)
orig_vars.pop('__weakref__', None)
return metaclass(cls.__name__, cls.__bases__, orig_vars)
return wrapper
from secop.lib import formatExtendedStack, mkthread
from secop.lib.parsing import format_time
from secop.errors import ConfigError, ProgrammingError
from secop.protocol import status
from secop.datatypes import DataType, EnumType, TupleOf, StringType, FloatRange, get_datatype
EVENT_ONLY_ON_CHANGED_VALUES = False
class Param(object):
"""storage for Parameter settings + value + qualifiers
if readonly is False, the value can be changed (by code, or remote)
if no default is given, the parameter MUST be specified in the configfile
during startup, value is initialized with the default value or
from the config file if specified there
poll can be:
- False (never poll this parameter)
- True (poll this ever pollinterval)
- positive int (poll every N(th) pollinterval)
- negative int (normally poll every N(th) pollinterval, if module is busy, poll every pollinterval)
note: Drivable (and derived classes) poll with 10 fold frequency if module is busy....
"""
def __init__(self,
description,
datatype=None,
default=Ellipsis,
unit=None,
readonly=True,
export=True,
group='',
poll=False):
if not isinstance(datatype, DataType):
if issubclass(datatype, DataType):
# goodie: make an instance from a class (forgotten ()???)
datatype = datatype()
else:
raise ValueError(
'datatype MUST be derived from class DataType!')
self.description = description
self.datatype = datatype
self.default = default
self.unit = unit
self.readonly = readonly
self.export = export
self.group = group
# note: auto-converts True/False to 1/0 which yield the expected
# behaviour...
self.poll = int(poll)
# internal caching: value and timestamp of last change...
self.value = default
self.timestamp = 0
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, ', '.join(
['%s=%r' % (k, v) for k, v in sorted(self.__dict__.items())]))
def copy(self):
# return a copy of ourselfs
return Param(description=self.description,
datatype=self.datatype,
default=self.default,
unit=self.unit,
readonly=self.readonly,
export=self.export,
group=self.group,
poll=self.poll,
)
def as_dict(self, static_only=False):
# used for serialisation only
res = dict(
description=self.description,
readonly=self.readonly,
datatype=self.datatype.export_datatype(),
)
if self.unit:
res['unit'] = self.unit
if self.group:
res['group'] = self.group
if not static_only:
res['value'] = self.value
if self.timestamp:
res['timestamp'] = format_time(self.timestamp)
return res
def export_value(self):
return self.datatype.export_value(self.value)
class Override(object):
def __init__(self, **kwds):
self.kwds = kwds
def apply(self, paramobj):
if isinstance(paramobj, Param):
for k, v in self.kwds.items():
if hasattr(paramobj, k):
setattr(paramobj, k, v)
return paramobj
else:
raise ProgrammingError(
"Can not apply Override(%s=%r) to %r: non-existing property!" %
(k, v, paramobj))
else:
raise ProgrammingError(
"Overrides can only be applied to Param's, %r is none!" %
paramobj)
# storage for Commands settings (description + call signature...)
class Command(object):
def __init__(self, description, arguments=None, result=None):
# descriptive text for humans
self.description = description
# list of datatypes for arguments
self.arguments = arguments or []
# datatype for result
self.resulttype = result
def __repr__(self):
return '%s(%s)' % (self.__class__.__name__, ', '.join(
['%s=%r' % (k, v) for k, v in sorted(self.__dict__.items())]))
def as_dict(self):
# used for serialisation only
return dict(
description=self.description,
arguments=[arg.export_datatype() for arg in self.arguments],
resulttype=self.resulttype.export_datatype() if self.resulttype else None,
)
# Meta class
# warning: MAGIC!
class ModuleMeta(type):
def __new__(mcs, name, bases, attrs):
newtype = type.__new__(mcs, name, bases, attrs)
if '__constructed__' in attrs:
return newtype
# merge properties, Param and commands from all sub-classes
for entry in ['properties', 'parameters', 'commands']:
newentry = {}
for base in reversed(bases):
if hasattr(base, entry):
newentry.update(getattr(base, entry))
newentry.update(attrs.get(entry, {}))
setattr(newtype, entry, newentry)
# apply Overrides from all sub-classes
newparams = getattr(newtype, 'parameters')
for base in reversed(bases):
overrides = getattr(base, 'overrides', {})
for n, o in overrides.items():
newparams[n] = o.apply(newparams[n].copy())
for n, o in attrs.get('overrides', {}).items():
newparams[n] = o.apply(newparams[n].copy())
# check validity of Param entries
for pname, pobj in newtype.parameters.items():
# XXX: allow dicts for overriding certain aspects only.
if not isinstance(pobj, Param):
raise ProgrammingError('%r: Params entry %r should be a '
'Param object!' % (name, pname))
# XXX: create getters for the units of params ??
# wrap of reading/writing funcs
rfunc = attrs.get('read_' + pname, None)
for base in bases:
if rfunc is not None:
break
rfunc = getattr(base, 'read_' + pname, None)
def wrapped_rfunc(self, maxage=0, pname=pname, rfunc=rfunc):
if rfunc:
self.log.debug("rfunc(%s): call %r" % (pname, rfunc))
value = rfunc(self, maxage)
else:
# return cached value
self.log.debug("rfunc(%s): return cached value" % pname)
value = self.parameters[pname].value
setattr(self, pname, value) # important! trigger the setter
return value
if rfunc:
wrapped_rfunc.__doc__ = rfunc.__doc__
if getattr(rfunc, '__wrapped__', False) is False:
setattr(newtype, 'read_' + pname, wrapped_rfunc)
wrapped_rfunc.__wrapped__ = True
if not pobj.readonly:
wfunc = attrs.get('write_' + pname, None)
for base in bases:
if wfunc is not None:
break
wfunc = getattr(base, 'write_' + pname, None)
def wrapped_wfunc(self, value, pname=pname, wfunc=wfunc):
self.log.debug("wfunc(%s): set %r" % (pname, value))
pobj = self.parameters[pname]
value = pobj.datatype.validate(value)
if wfunc:
self.log.debug('calling %r(%r)' % (wfunc, value))
value = wfunc(self, value) or value
# XXX: use setattr or direct manipulation
# of self.parameters[pname]?
setattr(self, pname, value)
return value
if wfunc:
wrapped_wfunc.__doc__ = wfunc.__doc__
if getattr(wfunc, '__wrapped__', False) is False:
setattr(newtype, 'write_' + pname, wrapped_wfunc)
wrapped_wfunc.__wrapped__ = True
def getter(self, pname=pname):
return self.parameters[pname].value
def setter(self, value, pname=pname):
pobj = self.parameters[pname]
value = pobj.datatype.validate(value)
pobj.timestamp = time.time()
if not EVENT_ONLY_ON_CHANGED_VALUES or (value != pobj.value):
pobj.value = value
# also send notification
if self.parameters[pname].export:
self.log.debug('%s is now %r' % (pname, value))
self.DISPATCHER.announce_update(self, pname, pobj)
setattr(newtype, pname, property(getter, setter))
# also collect/update information about Command's
setattr(newtype, 'commands', getattr(newtype, 'commands', {}))
for attrname in attrs:
if attrname.startswith('do_'):
if attrname[3:] in newtype.commands:
continue
value = getattr(newtype, attrname)
if isinstance(value, types.MethodType):
argspec = inspect.getargspec(value)
if argspec[0] and argspec[0][0] == 'self':
del argspec[0][0]
newtype.commands[attrname[3:]] = Command(
getattr(value, '__doc__'), argspec.args,
None) # XXX: how to find resulttype?
attrs['__constructed__'] = True
return newtype
# Basic module class
#
# within Modules, parameters should only be addressed as self.<pname>
# i.e. self.value, self.target etc...
# these are accesses to the cached version.
# they can also be written to
# (which auto-calls self.write_<pname> and generate an async update)
# if you want to 'update from the hardware', call self.read_<pname>
# the return value of this method will be used as the new cached value and
# be returned.
@add_metaclass(ModuleMeta)
class Module(object):
"""Basic Module, doesn't do much"""
# static properties, definitions in derived classes should overwrite earlier ones.
# how to configure some stuff which makes sense to take from configfile???
properties = {
'group': None, # some Modules may be grouped together
'meaning': None, # XXX: ???
'priority': None, # XXX: ???
'visibility': None, # XXX: ????
'description': "The manufacturer forgot to set a meaningful description. please nag him!",
# what else?
}
# parameter and commands are auto-merged upon subclassing
# parameters = {
# 'description': Param('short description of this module and its function', datatype=StringType(), default='no specified'),
# }
commands = {}
DISPATCHER = None
def __init__(self, logger, cfgdict, devname, dispatcher):
# remember the dispatcher object (for the async callbacks)
self.DISPATCHER = dispatcher
self.log = logger
self.name = devname
# make local copies of parameter
params = {}
for k, v in list(self.parameters.items()):
params[k] = v.copy()
self.parameters = params
# make local copies of properties
props = {}
for k, v in list(self.properties.items()):
props[k] = v
self.properties = props
# check and apply properties specified in cfgdict
# moduleproperties are to be specified as
# '.<propertyname>=<propertyvalue>'
for k, v in list(cfgdict.items()): # keep list() as dict may change during iter
if k[0] == '.':
if k[1:] in self.properties:
self.properties[k[1:]] = v
del cfgdict[k]
# derive automatic properties
mycls = self.__class__
myclassname = '%s.%s' % (mycls.__module__, mycls.__name__)
self.properties['_implementation'] = myclassname
self.properties['interface_class'] = [
b.__name__ for b in mycls.__mro__ if b.__module__.startswith('secop.modules')]
#self.properties['interface'] = self.properties['interfaces'][0]
# remove unset (default) module properties
for k, v in list(self.properties.items()): # keep list() as dict may change during iter
if v is None:
del self.properties[k]
# check and apply parameter_properties
# specified as '<paramname>.<propertyname> = <propertyvalue>'
for k, v in list(cfgdict.items()): # keep list() as dict may change during iter
if '.' in k[1:]:
paramname, propname = k.split('.', 1)
if paramname in self.parameters:
paramobj = self.parameters[paramname]
if propname == 'datatype':
paramobj.datatype = get_datatype(cfgdict.pop(k))
elif hasattr(paramobj, propname):
setattr(paramobj, propname, v)
del cfgdict[k]
# check config for problems
# only accept config items specified in parameters
for k, v in cfgdict.items():
if k not in self.parameters:
raise ConfigError(
'Module %s:config Parameter %r '
'not unterstood! (use one of %s)' %
(self.name, k, ', '.join(self.parameters)))
# complain if a Param entry has no default value and
# is not specified in cfgdict
for k, v in self.parameters.items():
if k not in cfgdict:
if v.default is Ellipsis and k != 'value':
# Ellipsis is the one single value you can not specify....
raise ConfigError('Module %s: Parameter %r has no default '
'value and was not given in config!' %
(self.name, k))
# assume default value was given
cfgdict[k] = v.default
# replace CLASS level Param objects with INSTANCE level ones
self.parameters[k] = self.parameters[k].copy()
# now 'apply' config:
# pass values through the datatypes and store as attributes
for k, v in cfgdict.items():
if k == 'value':
continue
# apply datatype, complain if type does not fit
datatype = self.parameters[k].datatype
if datatype is not None:
# only check if datatype given
try:
v = datatype.validate(v)
except (ValueError, TypeError):
self.log.exception(formatExtendedStack())
raise
# raise ConfigError('Module %s: config parameter %r:\n%r' %
# (self.name, k, e))
setattr(self, k, v)
def init(self):
# may be overriden in derived classes to init stuff
self.log.debug('empty init()')
mkthread(self.late_init)
def late_init(self):
# this runs async somewhen after init
self.log.debug('late init()')
class Readable(Module):
"""Basic readable Module
providing the readonly parameter 'value' and 'status'
"""
parameters = {
'value': Param('current value of the Module', readonly=True, default=0.,
datatype=FloatRange(), unit='', poll=True),
'pollinterval': Param('sleeptime between polls', default=5,
readonly=False, datatype=FloatRange(0.1, 120), ),
'status': Param('current status of the Module', default=(status.OK, ''),
datatype=TupleOf(
EnumType(**{
'IDLE': status.OK,
'BUSY': status.BUSY,
'WARN': status.WARN,
'UNSTABLE': status.UNSTABLE,
'ERROR': status.ERROR,
'UNKNOWN': status.UNKNOWN
}), StringType()),
readonly=True, poll=True),
}
def init(self):
Module.init(self)
self._pollthread = mkthread(self.__pollThread)
def __pollThread(self):
try:
self.__pollThread_inner()
except Exception as e:
self.log.exception(e)
print(formatExtendedStack())
def __pollThread_inner(self):
"""super simple and super stupid per-module polling thread"""
i = 0
while True:
fastpoll = self.poll(i)
i += 1
try:
time.sleep(self.pollinterval * (0.1 if fastpoll else 1))
except TypeError:
time.sleep(min(self.pollinterval)
if fastpoll else max(self.pollinterval))
def poll(self, nr=0):
# Just poll all parameters regularly where polling is enabled
for pname, pobj in self.parameters.items():
if not pobj.poll:
continue
if nr % abs(int(pobj.poll)) == 0:
# poll every 'pobj.poll' iteration
rfunc = getattr(self, 'read_' + pname, None)
if rfunc:
try:
rfunc()
except Exception: # really all!
pass
class Writable(Readable):
"""Basic Writable Module
providing a settable 'target' parameter to those of a Readable
"""
parameters = {
'target': Param(
'target value of the Module',
default=0.,
readonly=False,
datatype=FloatRange(),
),
}
# XXX: commands ???? auto deriving working well enough?
class Drivable(Writable):
"""Basic Drivable Module
provides a stop command to interrupt actions.
Also status gets extended with a BUSY state indicating a running action.
"""
# improved polling: may poll faster if module is BUSY
def poll(self, nr=0):
# poll status first
stat = self.read_status(0)
fastpoll = stat[0] == status.BUSY
for pname, pobj in self.parameters.items():
if not pobj.poll:
continue
if pname == 'status':
# status was already polled above
continue
if ((int(pobj.poll) < 0) and fastpoll) or (
nr % abs(int(pobj.poll))) == 0:
# poll always if pobj.poll is negative and fastpoll (i.e. Module is busy)
# otherwise poll every 'pobj.poll' iteration
rfunc = getattr(self, 'read_' + pname, None)
if rfunc:
try:
rfunc()
except Exception: # really all!
pass
return fastpoll
def do_stop(self):
"""default implementation of the stop command
by default does nothing."""
class Communicator(Module):
"""Basic communication Module
providing no parameters, but a 'communicate' command.
"""
commands = {
"communicate": Command("provides the simplest mean to communication",
arguments=[StringType()],
result=StringType()
),
}