diff --git a/etc/ccr12.cfg b/etc/ccr12.cfg new file mode 100644 index 0000000..6f35940 --- /dev/null +++ b/etc/ccr12.cfg @@ -0,0 +1,45 @@ +[equipment] +id=ccr12 + +[interface tcp] +interface=tcp +bindto=0.0.0.0 +bindport=10767 +# protocol to use for this interface +framing=eol +encoding=demo + +[device automatik] +class=secop_mlz.entangle.NamedDigitalOutput +tangodevice=tango://ccr12:10000/box/plc/_automatik +mapping=dict(Off=0,p1=1,p2=2) + +[device compressor] +class=secop_mlz.entangle.NamedDigitalOutput +tangodevice=tango://ccr12:10000/box/plc/_cooler_onoff +mapping=dict(Off=0,On=1) + +[device gas] +class=secop_mlz.entangle.NamedDigitalOutput +tangodevice=tango://ccr12:10000/box/plc/_gas_onoff +mapping=dict(Off=0,On=1) + +[device vacuum] +class=secop_mlz.entangle.NamedDigitalOutput +tangodevice=tango://ccr12:10000/box/plc/_vacuum_onoff +mapping=dict(Off=0,On=1) + +[device p1] +class=secop_mlz.entangle.AnalogInput +tangodevice=tango://ccr12:10000/box/plc/_p1 + +[device p2] +class=secop_mlz.entangle.AnalogInput +tangodevice=tango://ccr12:10000/box/plc/_p2 + +[device curve_p2] +class=secop_mlz.entangle.NamedDigitalInput +tangodevice=tango://ccr12:10000/box/plc/_curve +value.default='undefined' +mapping=dict(curve1=1,curve2=2,curve3=3) + diff --git a/etc/cryo.cfg b/etc/cryo.cfg index ab4467c..474e8b2 100644 --- a/etc/cryo.cfg +++ b/etc/cryo.cfg @@ -5,6 +5,7 @@ description = short description This is a very long description providing all the glory details in all the glory details about the stuff we are describing + [interface tcp] interface=tcp bindto=0.0.0.0 @@ -18,7 +19,7 @@ encoding=demo # some (non-defaut) module properties .group=very important/stuff # class of module: -class=devices.cryo.Cryostat +class=secop_demo.cryo.Cryostat # some parameters jitter=0.1 diff --git a/etc/demo.cfg b/etc/demo.cfg index 0f4f894..59d8f80 100644 --- a/etc/demo.cfg +++ b/etc/demo.cfg @@ -10,34 +10,34 @@ framing=eol encoding=demo [device heatswitch] -class=devices.demo.Switch +class=secop_demo.demo.Switch switch_on_time=5 switch_off_time=10 [device mf] -class=devices.demo.MagneticField +class=secop_demo.demo.MagneticField heatswitch = heatswitch [device ts] -class=devices.demo.SampleTemp +class=secop_demo.demo.SampleTemp sensor = 'Q1329V7R3' ramp = 4 target = 10 default = 10 [device tc1] -class=devices.demo.CoilTemp +class=secop_demo.demo.CoilTemp sensor="X34598T7" [device tc2] -class=devices.demo.CoilTemp +class=secop_demo.demo.CoilTemp sensor="X39284Q8' [device label] -class=devices.demo.Label +class=secop_demo.demo.Label system=Cryomagnet MX15 subdev_mf=mf subdev_ts=ts #[device vt] -#class=devices.demo.ValidatorTest +#class=secop_demo.demo.ValidatorTest diff --git a/etc/epics.cfg b/etc/epics.cfg index 69a7dab..159b3f0 100644 --- a/etc/epics.cfg +++ b/etc/epics.cfg @@ -17,23 +17,23 @@ framing=eol encoding=demo [device tc1] -class=devices.demo.CoilTemp +class=secop_demo.demo.CoilTemp sensor="X34598T7" [device tc2] -class=devices.demo.CoilTemp +class=secop_demo.demo.CoilTemp sensor="X39284Q8' [device sensor1] -class=devices.epics.EpicsReadable +class=secop_ess.epics.EpicsReadable epics_version="v4" .group="Lakeshore336" value_pv="DEV:KRDG1" [device loop1] -class=devices.epics.EpicsTempCtrl +class=secop_ess.epics.EpicsTempCtrl epics_version="v4" .group="Lakeshore336" @@ -43,14 +43,14 @@ heaterrange_pv="DEV:RANGE_S1" [device sensor2] -class=devices.epics.EpicsReadable +class=secop_ess.epics.EpicsReadable epics_version="v4" .group="Lakeshore336" value_pv="DEV:KRDG2" [device loop2] -class=devices.epics.EpicsTempCtrl +class=secop_ess.epics.EpicsTempCtrl epics_version="v4" .group="Lakeshore336" diff --git a/etc/test.cfg b/etc/test.cfg index 16ece1b..a9603cc 100644 --- a/etc/test.cfg +++ b/etc/test.cfg @@ -11,21 +11,21 @@ encoding=demo [device LN2] -class=devices.test.LN2 +class=secop_demo.test.LN2 [device heater] -class=devices.test.Heater +class=secop_demo.test.Heater maxheaterpower=10 [device T1] -class=devices.test.Temp +class=secop_demo.test.Temp sensor="X34598T7" [device T2] -class=devices.demo.CoilTemp +class=secop_demo.demo.CoilTemp sensor="X34598T8" [device T3] -class=devices.demo.CoilTemp +class=secop_demo.demo.CoilTemp sensor="X34598T9" diff --git a/secop/client/baseclient.py b/secop/client/baseclient.py index 080a63a..91a3b95 100644 --- a/secop/client/baseclient.py +++ b/secop/client/baseclient.py @@ -156,6 +156,7 @@ class Client(object): self.log = mlzlog.log.getChild('client', True) else: class logStub(object): + def info(self, *args): pass debug = info @@ -343,14 +344,17 @@ class Client(object): def _issueDescribe(self): _, self.equipment_id, describing_data = self._communicate('describe') try: - describing_data = self._decode_substruct(['modules'], describing_data) + describing_data = self._decode_substruct( + ['modules'], describing_data) for modname, module in describing_data['modules'].items(): - describing_data['modules'][modname] = self._decode_substruct(['parameters', 'commands'], module) + describing_data['modules'][modname] = self._decode_substruct( + ['parameters', 'commands'], module) self.describing_data = describing_data for module, moduleData in self.describing_data['modules'].items(): - for parameter, parameterData in moduleData['parameters'].items(): + for parameter, parameterData in moduleData[ + 'parameters'].items(): datatype = get_datatype(parameterData['datatype']) self.describing_data['modules'][module]['parameters'] \ [parameter]['datatype'] = datatype diff --git a/secop/datatypes.py b/secop/datatypes.py index e7d1a4d..46726be 100644 --- a/secop/datatypes.py +++ b/secop/datatypes.py @@ -22,12 +22,21 @@ """Define validated data types.""" - from .errors import ProgrammingError from collections import OrderedDict +# Only export these classes for 'from secop.datatypes import *' +__all__ = [ + "DataType", + "FloatRange", "IntRange", + "BoolType", "EnumType", + "BLOBType", "StringType", + "TupleOf", "ArrayOf", "StructOf", +] # base class for all DataTypes + + class DataType(object): as_json = ['undefined'] @@ -39,6 +48,11 @@ class DataType(object): """returns a python object fit for external serialisation or logging""" raise NotImplemented + def from_string(self, text): + """interprets a given string and returns a validated (internal) value""" + # to evaluate values from configfiles, etc... + raise NotImplemented + # goodie: if called, validate def __call__(self, value): return self.validate(value) @@ -52,7 +66,7 @@ class FloatRange(DataType): self.max = None if max is None else float(max) # note: as we may compare to Inf all comparisons would be false if (self.min or float('-inf')) <= (self.max or float('+inf')): - if min == None and max == None: + if min is None and max is None: self.as_json = ['double'] else: self.as_json = ['double', min, max] @@ -65,9 +79,11 @@ class FloatRange(DataType): except: raise ValueError('Can not validate %r to float' % value) if self.min is not None and value < self.min: - raise ValueError('%r should not be less then %s' % (value, self.min)) + raise ValueError('%r should not be less then %s' % + (value, self.min)) if self.max is not None and value > self.max: - raise ValueError('%r should not be greater than %s' % (value, self.max)) + raise ValueError('%r should not be greater than %s' % + (value, self.max)) if None in (self.min, self.max): return value if self.min <= value <= self.max: @@ -76,9 +92,10 @@ class FloatRange(DataType): (value, self.min, self.max)) def __repr__(self): - if self.max != None: - return "FloatRange(%r, %r)" % (float('-inf') if self.min is None else self.min, self.max) - if self.min != None: + if self.max is not None: + return "FloatRange(%r, %r)" % ( + float('-inf') if self.min is None else self.min, self.max) + if self.min is not None: return "FloatRange(%r)" % self.min return "FloatRange()" @@ -86,15 +103,20 @@ class FloatRange(DataType): """returns a python object fit for serialisation""" return float(value) + def from_string(self, text): + value = float(text) + return self.validate(value) + class IntRange(DataType): """Restricted int type""" + def __init__(self, min=None, max=None): self.min = int(min) if min is not None else min self.max = int(max) if max is not None else max if self.min is not None and self.max is not None and self.min > self.max: raise ValueError('Max must be larger then min!') - if self.min == None and self.max == None: + if self.min is None and self.max is None: self.as_json = ['int'] else: self.as_json = ['int', self.min, self.max] @@ -104,10 +126,10 @@ class IntRange(DataType): value = int(value) if self.min is not None and value < self.min: raise ValueError('%r should be an int between %d and %d' % - (value, self.min, self.max or 0)) + (value, self.min, self.max or 0)) if self.max is not None and value > self.max: raise ValueError('%r should be an int between %d and %d' % - (value, self.min or 0, self.max)) + (value, self.min or 0, self.max)) return value except: raise ValueError('Can not validate %r to int' % value) @@ -123,15 +145,20 @@ class IntRange(DataType): """returns a python object fit for serialisation""" return int(value) + def from_string(self, text): + value = int(text) + return self.validate(value) + class EnumType(DataType): as_json = ['enum'] + def __init__(self, *args, **kwds): # enum keys are ints! check self.entries = {} num = 0 for arg in args: - if type(arg) != str: + if not isinstance(arg, str): print arg, type(arg) raise ValueError('EnumType entries MUST be strings!') self.entries[num] = arg @@ -139,19 +166,24 @@ class EnumType(DataType): for k, v in kwds.items(): v = int(v) if v in self.entries: - raise ValueError('keyword argument %r=%d is already assigned %r', k, v, self.entries[v]) + raise ValueError( + 'keyword argument %r=%d is already assigned %r', + k, + v, + self.entries[v]) self.entries[v] = k if len(self.entries) == 0: raise ValueError('Empty enums ae not allowed!') self.reversed = {} - for k,v in self.entries.items(): + for k, v in self.entries.items(): if v in self.reversed: raise ValueError('Mapping for %r=%r is not Unique!', v, k) self.reversed[v] = k self.as_json = ['enum', self.reversed.copy()] def __repr__(self): - return "EnumType(%s)" % ', '.join(['%s=%d' % (v,k) for k,v in self.entries.items()]) + return "EnumType(%s)" % ', '.join( + ['%s=%d' % (v, k) for k, v in self.entries.items()]) def export(self, value): """returns a python object fit for serialisation""" @@ -159,7 +191,8 @@ class EnumType(DataType): return self.reversed[value] if int(value) in self.entries: return int(value) - raise ValueError('%r is not one of %s', str(value), ', '.join(self.reversed.keys())) + raise ValueError('%r is not one of %s', str( + value), ', '.join(self.reversed.keys())) def validate(self, value): """return the validated (internal) value or raise""" @@ -167,10 +200,16 @@ class EnumType(DataType): return value if int(value) in self.entries: return self.entries[int(value)] - raise ValueError('%r is not one of %s', str(value), ', '.join(map(str,self.entries.keys()))) + raise ValueError('%r is not one of %s', str(value), + ', '.join(map(str, self.entries.keys()))) + + def from_string(self, text): + value = text + return self.validate(value) class BLOBType(DataType): + def __init__(self, minsize=0, maxsize=None): self.minsize = minsize self.maxsize = maxsize @@ -194,19 +233,26 @@ class BLOBType(DataType): raise ValueError('%r has the wrong type!', value) size = len(value) if size < self.minsize: - raise ValueError('%r must be at least %d bytes long!', value, self.minsize) + raise ValueError( + '%r must be at least %d bytes long!', value, self.minsize) if self.maxsize is not None: if size > self.maxsize: - raise ValueError('%r must be at most %d bytes long!', value, self.maxsize) + raise ValueError( + '%r must be at most %d bytes long!', value, self.maxsize) return value def export(self, value): """returns a python object fit for serialisation""" return b'%s' % value + def from_string(self, text): + value = text + return self.validate(value) + class StringType(DataType): as_json = ['string'] + def __init__(self, minsize=0, maxsize=None): self.minsize = minsize self.maxsize = maxsize @@ -219,7 +265,8 @@ class StringType(DataType): def __repr__(self): if self.maxsize: - return 'StringType(%s, %s)' % (str(self.minsize), str(self.maxsize)) + return 'StringType(%s, %s)' % ( + str(self.minsize), str(self.maxsize)) if self.minsize: return 'StringType(%d)' % str(self.minsize) return 'StringType()' @@ -230,22 +277,31 @@ class StringType(DataType): raise ValueError('%r has the wrong type!', value) size = len(value) if size < self.minsize: - raise ValueError('%r must be at least %d bytes long!', value, self.minsize) + raise ValueError( + '%r must be at least %d bytes long!', value, self.minsize) if self.maxsize is not None: if size > self.maxsize: - raise ValueError('%r must be at most %d bytes long!', value, self.maxsize) + raise ValueError( + '%r must be at most %d bytes long!', value, self.maxsize) if '\0' in value: - raise ValueError('Strings are not allowed to embed a \\0! Use a Blob instead!') + raise ValueError( + 'Strings are not allowed to embed a \\0! Use a Blob instead!') return value def export(self, value): """returns a python object fit for serialisation""" return '%s' % value + def from_string(self, text): + value = text + return self.validate(value) # Bool is a special enum + + class BoolType(DataType): as_json = ['bool'] + def __repr__(self): return 'BoolType()' @@ -261,23 +317,31 @@ class BoolType(DataType): """returns a python object fit for serialisation""" return True if self.validate(value) else False + def from_string(self, text): + value = text + return self.validate(value) # # nested types # + + class ArrayOf(DataType): + def __init__(self, subtype, minsize_or_size=None, maxsize=None): if maxsize is None: maxsize = minsize_or_size self.minsize = minsize_or_size self.maxsize = maxsize if self.minsize is not None and self.maxsize is not None and \ - self.minsize > self.maxsize: - raise ValueError('minsize must be less than or equal to maxsize!') + self.minsize > self.maxsize: + raise ValueError('minsize must be less than or equal to maxsize!') if not isinstance(subtype, DataType): - raise ValueError('ArrayOf only works with DataType objs as first argument!') + raise ValueError( + 'ArrayOf only works with DataType objs as first argument!') self.subtype = subtype - self.as_json = ['array', self.subtype.as_json, self.minsize, self.maxsize] + self.as_json = ['array', self.subtype.as_json, + self.minsize, self.maxsize] if self.minsize is not None and self.minsize < 0: raise ValueError('Minimum size must be >= 0!') if self.maxsize is not None and self.maxsize < 1: @@ -286,32 +350,43 @@ class ArrayOf(DataType): raise ValueError('Maximum size must be >= Minimum size') def __repr__(self): - return 'ArrayOf(%s, %s, %s)' % (repr(self.subtype), self.minsize, self.maxsize) + return 'ArrayOf(%s, %s, %s)' % ( + repr(self.subtype), self.minsize, self.maxsize) def validate(self, value): """validate a external representation to an internal one""" if isinstance(value, (tuple, list)): # check number of elements if self.minsize is not None and len(value) < self.minsize: - raise ValueError('Array too small, needs at least %d elements!', self.minsize) + raise ValueError( + 'Array too small, needs at least %d elements!', + self.minsize) if self.maxsize is not None and len(value) > self.maxsize: - raise ValueError('Array too big, holds at most %d elements!', self.minsize) + raise ValueError( + 'Array too big, holds at most %d elements!', self.minsize) # apply subtype valiation to all elements and return as list return [self.subtype.validate(elem) for elem in value] - raise ValueError('Can not convert %s to ArrayOf DataType!', repr(value)) + raise ValueError( + 'Can not convert %s to ArrayOf DataType!', repr(value)) def export(self, value): """returns a python object fit for serialisation""" return [self.subtype.export(elem) for elem in value] + def from_string(self, text): + value = eval(text) # XXX: !!! + return self.validate(value) + class TupleOf(DataType): + def __init__(self, *subtypes): if not subtypes: raise ValueError('Empty tuples are not allowed!') for subtype in subtypes: if not isinstance(subtype, DataType): - raise ValueError('TupleOf only works with DataType objs as arguments!') + raise ValueError( + 'TupleOf only works with DataType objs as arguments!') self.subtypes = subtypes self.as_json = ['tuple', [subtype.as_json for subtype in subtypes]] @@ -323,65 +398,84 @@ class TupleOf(DataType): # keep the ordering! try: if len(value) != len(self.subtypes): - raise ValueError('Illegal number of Arguments! Need %d arguments.', len(self.subtypes)) + raise ValueError( + 'Illegal number of Arguments! Need %d arguments.', len( + self.subtypes)) # validate elements and return as list - return [sub.validate(elem) for sub,elem in zip(self.subtypes, value)] + return [sub.validate(elem) + for sub, elem in zip(self.subtypes, value)] except Exception as exc: raise ValueError('Can not validate:', str(exc)) def export(self, value): """returns a python object fit for serialisation""" - return [sub.export(elem) for sub,elem in zip(self.subtypes, value)] + return [sub.export(elem) for sub, elem in zip(self.subtypes, value)] + + def from_string(self, text): + value = eval(text) # XXX: !!! + return self.validate(tuple(value)) class StructOf(DataType): + def __init__(self, **named_subtypes): if not named_subtypes: raise ValueError('Empty structs are not allowed!') for name, subtype in named_subtypes.items(): if not isinstance(subtype, DataType): - raise ProgrammingError('StructOf only works with named DataType objs as keyworded arguments!') + raise ProgrammingError( + 'StructOf only works with named DataType objs as keyworded arguments!') if not isinstance(name, (str, unicode)): - raise ProgrammingError('StructOf only works with named DataType objs as keyworded arguments!') + raise ProgrammingError( + 'StructOf only works with named DataType objs as keyworded arguments!') self.named_subtypes = named_subtypes - self.as_json = ['struct', dict((n,s.as_json) for n,s in named_subtypes.items())] + self.as_json = ['struct', dict((n, s.as_json) + for n, s in named_subtypes.items())] def __repr__(self): - return 'StructOf(%s)' % ', '.join(['%s=%s'%(n,repr(st)) for n,st in self.named_subtypes.iteritems()]) + return 'StructOf(%s)' % ', '.join( + ['%s=%s' % (n, repr(st)) for n, st in self.named_subtypes.iteritems()]) def validate(self, value): """return the validated value or raise""" try: if len(value.keys()) != len(self.named_subtypes.keys()): - raise ValueError('Illegal number of Arguments! Need %d arguments.', len(self.namd_subtypes.keys())) + raise ValueError( + 'Illegal number of Arguments! Need %d arguments.', len( + self.namd_subtypes.keys())) # validate elements and return as dict return dict((str(k), self.named_subtypes[k].validate(v)) - for k,v in value.items()) + for k, v in value.items()) except Exception as exc: - raise ValueError('Can not validate %s: %s', repr(value),str(exc)) + raise ValueError('Can not validate %s: %s', repr(value), str(exc)) def export(self, value): """returns a python object fit for serialisation""" if len(value.keys()) != len(self.named_subtypes.keys()): - raise ValueError('Illegal number of Arguments! Need %d arguments.', len(self.namd_subtypes.keys())) - return dict((str(k),self.named_subtypes[k].export(v)) - for k,v in value.items()) - - + raise ValueError( + 'Illegal number of Arguments! Need %d arguments.', len( + self.namd_subtypes.keys())) + return dict((str(k), self.named_subtypes[k].export(v)) + for k, v in value.items()) + def from_string(self, text): + value = eval(text) # XXX: !!! + return self.validate(dict(value)) # XXX: derive from above classes automagically! DATATYPES = dict( - bool = lambda : BoolType(), - int = lambda _min=None, _max=None: IntRange(_min, _max), - double = lambda _min=None, _max=None: FloatRange(_min, _max), - blob = lambda _min=None, _max=None: BLOBType(_min, _max), - string = lambda _min=None, _max=None: StringType(_min, _max), - array = lambda subtype, _min=None, _max=None: ArrayOf(get_datatype(subtype), _min, _max), - tuple = lambda subtypes: TupleOf(*map(get_datatype,subtypes)), - enum = lambda kwds: EnumType(**kwds), - struct = lambda named_subtypes: StructOf(**dict((n,get_datatype(t)) for n,t in named_subtypes.items())), + bool=lambda: BoolType(), + int=lambda _min=None, _max=None: IntRange(_min, _max), + double=lambda _min=None, _max=None: FloatRange(_min, _max), + blob=lambda _min=None, _max=None: BLOBType(_min, _max), + string=lambda _min=None, _max=None: StringType(_min, _max), + array=lambda subtype, _min=None, _max=None: ArrayOf( + get_datatype(subtype), _min, _max), + tuple=lambda subtypes: TupleOf(*map(get_datatype, subtypes)), + enum=lambda kwds: EnumType(**kwds), + struct=lambda named_subtypes: StructOf( + **dict((n, get_datatype(t)) for n, t in named_subtypes.items())), ) @@ -392,12 +486,14 @@ def export_datatype(datatype): return datatype.as_json # important for getting the right datatype from formerly jsonified descr. + + def get_datatype(json): if json is None: return json if not isinstance(json, list): raise ValueError('Argument must be a properly formatted list!') - if len(json)<1: + if len(json) < 1: raise ValueError('can not validate %r', json) base = json[0] if base in DATATYPES: diff --git a/secop/errors.py b/secop/errors.py index 8600fd9..2791090 100644 --- a/secop/errors.py +++ b/secop/errors.py @@ -33,3 +33,11 @@ class ConfigError(SECoPServerError): class ProgrammingError(SECoPServerError): pass + + +class CommunicationError(SECoPServerError): + pass + + +class HardwareError(SECoPServerError): + pass diff --git a/secop/gui/mainwindow.py b/secop/gui/mainwindow.py index 5d3ae95..84d33a7 100644 --- a/secop/gui/mainwindow.py +++ b/secop/gui/mainwindow.py @@ -71,7 +71,6 @@ class MainWindow(QMainWindow): loadUi(self, 'mainwindow.ui') self.toolBar.hide() - self.lineEdit.hide() self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 70) @@ -108,6 +107,13 @@ class MainWindow(QMainWindow): 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)) diff --git a/secop/gui/modulectrl.py b/secop/gui/modulectrl.py index 399b99b..fc2782f 100644 --- a/secop/gui/modulectrl.py +++ b/secop/gui/modulectrl.py @@ -56,6 +56,7 @@ class ParameterButtons(QWidget): class ParameterGroup(QWidget): + def __init__(self, groupname, parent=None): super(ParameterGroup, self).__init__(parent) loadUi(self, 'paramgroup.ui') @@ -107,19 +108,17 @@ class ModuleCtrl(QWidget): self._node.newData.connect(self._updateValue) - def _initModuleWidgets(self): initValues = self._node.queryCache(self._module) 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',None) + group = props.get('group', None) if group is not None: allGroups.add(group) paramsByGroup.setdefault(group, []).append(param) @@ -139,7 +138,8 @@ class ModuleCtrl(QWidget): # check if there is a param of the same name too if group in params: # yes: create a widget for this as well - labelstr, buttons = self._makeEntry(param, initValues[param].value, nolabel=True, checkbox=checkbox, invert=True) + labelstr, buttons = self._makeEntry( + param, initValues[param].value, nolabel=True, checkbox=checkbox, invert=True) checkbox.setText(labelstr) # add to Layout (yes: ignore the label!) @@ -153,7 +153,8 @@ class ModuleCtrl(QWidget): for param in paramsByGroup[param]: if param == group: continue - label, buttons = self._makeEntry(param, initValues[param].value, checkbox=checkbox, invert=False) + label, buttons = self._makeEntry( + param, initValues[param].value, checkbox=checkbox, invert=False) # add to Layout self.paramGroupBox.layout().addWidget(label, row, 0) @@ -161,22 +162,29 @@ class ModuleCtrl(QWidget): 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) + # 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', param) == param: - label, buttons = self._makeEntry(param, initValues[param].value) + label, buttons = self._makeEntry( + param, initValues[param].value) # add to Layout self.paramGroupBox.layout().addWidget(label, row, 0) self.paramGroupBox.layout().addWidget(buttons, row, 1) row += 1 - - def _makeEntry(self, param, initvalue, nolabel=False, checkbox=None, invert=False): + def _makeEntry( + self, + param, + initvalue, + nolabel=False, + checkbox=None, + invert=False): props = self._node.getProperties(self._module, param) description = props.get('description', '') - unit = props.get('unit','') + unit = props.get('unit', '') if unit: labelstr = '%s (%s):' % (param, unit) @@ -186,7 +194,8 @@ class ModuleCtrl(QWidget): if checkbox and not invert: labelstr = ' ' + labelstr - buttons = ParameterButtons(self._module, param, initvalue, props['readonly']) + buttons = ParameterButtons( + self._module, param, initvalue, props['readonly']) buttons.setRequested.connect(self._set_Button_pressed) if description: @@ -199,7 +208,11 @@ class ModuleCtrl(QWidget): label.setFont(self._labelfont) if checkbox: - def stateChanged(newstate, buttons=buttons, label=None if nolabel else label, invert=invert): + 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: @@ -217,9 +230,6 @@ class ModuleCtrl(QWidget): return label, buttons - - - def _set_Button_pressed(self, module, parameter, target): sig = (module, parameter, target) if self._lastclick == sig: diff --git a/secop/gui/ui/mainwindow.ui b/secop/gui/ui/mainwindow.ui index aea8b85..78e6e76 100644 --- a/secop/gui/ui/mainwindow.ui +++ b/secop/gui/ui/mainwindow.ui @@ -14,8 +14,8 @@ secop-gui - - + + Qt::Horizontal @@ -23,7 +23,53 @@ - + + + 0 + + + + + + user + + + + + admin + + + + + expert + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Validate locally + + + true + + + + @@ -58,7 +104,7 @@ 0 0 1228 - 25 + 23 diff --git a/secop/gui/ui/modulectrl.ui b/secop/gui/ui/modulectrl.ui index cd7ea7d..33dcbfa 100644 --- a/secop/gui/ui/modulectrl.ui +++ b/secop/gui/ui/modulectrl.ui @@ -7,7 +7,7 @@ 0 0 230 - 121 + 195 @@ -92,6 +92,40 @@ + + + + Properties: + + + + 0 + + + 6 + + + 0 + + + 6 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + diff --git a/secop/gui/ui/parambuttons_select.ui b/secop/gui/ui/parambuttons_select.ui new file mode 100644 index 0000000..cea746b --- /dev/null +++ b/secop/gui/ui/parambuttons_select.ui @@ -0,0 +1,76 @@ + + + Form + + + + 0 + 0 + 730 + 33 + + + + Form + + + + 6 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Current: + + + + + + + Set: + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + diff --git a/secop/lib/__init__.py b/secop/lib/__init__.py index f8d64f8..db7e128 100644 --- a/secop/lib/__init__.py +++ b/secop/lib/__init__.py @@ -21,7 +21,34 @@ # ***************************************************************************** """Define helpers""" +import os +import re +import sys +import errno +import signal +import socket +import fnmatch +import linecache import threading +import traceback +import subprocess +import unicodedata +from os import path + + +class lazy_property(object): + """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 obj + obj.__dict__[self.__name__] = self._func(obj) + return obj.__dict__[self.__name__] class attrdict(dict): @@ -48,8 +75,11 @@ def get_class(spec): """loads a class given by string in dotted notaion (as python would do)""" modname, classname = spec.rsplit('.', 1) import importlib - module = importlib.import_module('secop.' + modname) - # module = __import__(spec) + if modname.startswith('secop'): + module = importlib.import_module(modname) + else: + # rarely needed by now.... + module = importlib.import_module('secop.' + modname) return getattr(module, classname) @@ -64,10 +94,6 @@ def mkthread(func, *args, **kwds): return t -import sys -import linecache -import traceback - def formatExtendedFrame(frame): ret = [] for key, value in frame.f_locals.iteritems(): @@ -79,6 +105,7 @@ def formatExtendedFrame(frame): ret.append('\n') return ret + def formatExtendedTraceback(exc_info=None): if exc_info is None: etype, value, tb = sys.exc_info() @@ -101,6 +128,7 @@ def formatExtendedTraceback(exc_info=None): 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'] @@ -120,6 +148,7 @@ def formatExtendedStack(level=1): 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. @@ -137,6 +166,63 @@ def formatException(cut=0, exc_info=None, verbose=False): 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 + + +def closeSocket(sock, socket=socket): + """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) + + if __name__ == '__main__': print "minimal testing: lib" d = attrdict(a=1, b=2) diff --git a/secop/devices/core.py b/secop/modules.py similarity index 75% rename from secop/devices/core.py rename to secop/modules.py index 5c61164..9249ef5 100644 --- a/secop/devices/core.py +++ b/secop/modules.py @@ -56,17 +56,15 @@ class PARAM(object): unit=None, readonly=True, export=True, - group=''): - if isinstance(description, PARAM): - # make a copy of a PARAM object - self.__dict__.update(description.__dict__) - return + 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 from datatypes!') + raise ValueError( + 'datatype MUST be derived from class DataType!') self.description = description self.datatype = datatype self.default = default @@ -74,6 +72,9 @@ class PARAM(object): 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 @@ -82,6 +83,18 @@ class PARAM(object): 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( @@ -99,11 +112,36 @@ class PARAM(object): res['timestamp'] = format_time(self.timestamp) return res + @property + def export_value(self): + return self.datatype.export(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.iteritems(): + 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 CMDs settings (description + call signature...) class CMD(object): - def __init__(self, description, arguments, result): + def __init__(self, description, arguments=[], result=None): # descriptive text for humans self.description = description # list of datatypes for arguments @@ -122,10 +160,10 @@ class CMD(object): arguments=map(export_datatype, self.arguments), resulttype=export_datatype(self.resulttype), ) + # Meta class # warning: MAGIC! - class DeviceMeta(type): def __new__(mcs, name, bases, attrs): @@ -142,37 +180,61 @@ class DeviceMeta(type): newentry.update(attrs.get(entry, {})) setattr(newtype, entry, newentry) + # apply Overrides from all sub-classes + newparams = getattr(newtype, 'PARAMS') + for base in reversed(bases): + overrides = getattr(base, 'OVERRIDES', {}) + for n, o in overrides.iteritems(): + newparams[n] = o.apply(newparams[n].copy()) + for n, o in attrs.get('OVERRIDES', {}).iteritems(): + newparams[n] = o.apply(newparams[n].copy()) + # check validity of PARAM entries for pname, pobj in newtype.PARAMS.items(): # XXX: allow dicts for overriding certain aspects only. if not isinstance(pobj, PARAM): - raise ProgrammingError('%r: device PARAM %r should be a ' + 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) setattr(self, pname, value) return value else: # return cached value + self.log.debug("rfunc(%s): return cached value" % pname) return self.PARAMS[pname].value if rfunc: wrapped_rfunc.__doc__ = rfunc.__doc__ - setattr(newtype, 'read_' + pname, wrapped_rfunc) + if getattr(rfunc, '__wrapped__', False) == 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: set %s to %r" % (pname, value)) + self.log.debug("wfunc(%s): set %r" % (pname, value)) pobj = self.PARAMS[pname] - value = pobj.datatype.validate(value) if pobj.datatype else value + 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.PARAMS[pname]? @@ -181,14 +243,16 @@ class DeviceMeta(type): if wfunc: wrapped_wfunc.__doc__ = wfunc.__doc__ - setattr(newtype, 'write_' + pname, wrapped_wfunc) + if getattr(wfunc, '__wrapped__', False) == False: + setattr(newtype, 'write_' + pname, wrapped_wfunc) + wrapped_wfunc.__wrapped__ = True def getter(self, pname=pname): return self.PARAMS[pname].value def setter(self, value, pname=pname): pobj = self.PARAMS[pname] - value = pobj.datatype.validate(value) if pobj.datatype else value + value = pobj.datatype.validate(value) pobj.timestamp = time.time() if not EVENT_ONLY_ON_CHANGED_VALUES or (value != pobj.value): pobj.value = value @@ -251,13 +315,7 @@ class Device(object): # make local copies of PARAMS params = {} for k, v in self.PARAMS.items()[:]: - #params[k] = PARAM(v) - # PARAM: type(v) -> PARAM - # type(v)(v) -> PARAM(v) - # EPICS_PARAM: type(v) -> EPICS_PARAM - # type(v)(v) -> EPICS_PARAM(v) - param_type = type(v) - params[k] = param_type(v) + params[k] = v.copy() self.PARAMS = params @@ -273,13 +331,13 @@ class Device(object): mycls = self.__class__ myclassname = '%s.%s' % (mycls.__module__, mycls.__name__) self.PROPERTIES['implementation'] = myclassname - self.PROPERTIES['interfaces'] = [b.__name__ for b in mycls.__mro__ - if b.__module__.startswith('secop.devices.core')] + self.PROPERTIES['interfaces'] = [ + 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 self.PROPERTIES.items(): - if v == None: + if v is None: del self.PROPERTIES[k] # check and apply parameter_properties @@ -312,13 +370,13 @@ class Device(object): cfgdict[k] = v.default # replace CLASS level PARAM objects with INSTANCE level ones - #self.PARAMS[k] = PARAM(self.PARAMS[k]) - param_type = type(self.PARAMS[k]) - self.PARAMS[k] = param_type(self.PARAMS[k]) + self.PARAMS[k] = self.PARAMS[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.PARAMS[k].datatype if datatype is not None: @@ -334,11 +392,7 @@ class Device(object): def init(self): # may be overriden in derived classes to init stuff - self.log.debug('init()') - - def _pollThread(self): - # may be overriden in derived classes to init stuff - self.log.debug('init()') + self.log.debug('empty init()') class Readable(Device): @@ -348,7 +402,7 @@ class Readable(Device): """ PARAMS = { 'value': PARAM('current value of the device', readonly=True, default=0., - datatype=FloatRange()), + datatype=FloatRange(), poll=True), 'pollinterval': PARAM('sleeptime between polls', default=5, readonly=False, datatype=FloatRange(0.1, 120), ), 'status': PARAM('current status of the device', default=(status.OK, ''), @@ -360,25 +414,38 @@ class Readable(Device): 'UNSTABLE': status.UNSTABLE, 'ERROR': status.ERROR, 'UNKNOWN': status.UNKNOWN - }), StringType() ), - readonly=True), + }), StringType()), + readonly=True, poll=True), } def init(self): Device.init(self) - self._pollthread = threading.Thread(target=self._pollThread) + self._pollthread = threading.Thread(target=self.__pollThread) self._pollthread.daemon = True self._pollthread.start() - def _pollThread(self): + def __pollThread(self): """super simple and super stupid per-module polling thread""" + i = 0 while True: - time.sleep(self.pollinterval) - for pname in self.PARAMS: - if pname != 'pollinterval': - rfunc = getattr(self, 'read_%s' % pname, None) - if rfunc: - rfunc() + i = 1 + try: + time.sleep(self.pollinterval) + except TypeError: + time.sleep(max(self.pollinterval)) + try: + self.poll(i) + except Exception: # really ALL + pass + + def poll(self, nr): + for pname, pobj in self.PARAMS.iteritems(): + if not pobj.poll: + continue + if 0 == nr % int(pobj.poll): + rfunc = getattr(self, 'read_' + pname, None) + if rfunc: + rfunc() class Driveable(Readable): @@ -387,9 +454,12 @@ class Driveable(Readable): providing a settable 'target' parameter to those of a Readable """ PARAMS = { - 'target': PARAM('target value of the device', default=0., readonly=False, - datatype=FloatRange(), - ), + 'target': PARAM( + 'target value of the device', + default=0., + readonly=False, + datatype=FloatRange(), + ), } # XXX: CMDS ???? auto deriving working well enough? diff --git a/secop/protocol/dispatcher.py b/secop/protocol/dispatcher.py index adb9c46..9578074 100644 --- a/secop/protocol/dispatcher.py +++ b/secop/protocol/dispatcher.py @@ -96,8 +96,8 @@ class Dispatcher(object): except Exception as err: self.log.exception(err) reply = msg.get_error( - errorclass='InternalError', - errorinfo=[formatException(), str(msg), formatExtendedStack()]) + errorclass='InternalError', errorinfo=[ + formatException(), str(msg), formatExtendedStack()]) else: self.log.debug('Can not handle msg %r' % msg) reply = self.unhandled(conn, msg) @@ -125,7 +125,7 @@ class Dispatcher(object): msg = Value( moduleobj.name, parameter=pname, - value=pobj.value, + value=pobj.export_value, t=pobj.timestamp) self.broadcast_event(msg) @@ -215,8 +215,9 @@ class Dispatcher(object): for modulename in self._export: module = self.get_module(modulename) # some of these need rework ! - mod_desc = {'parameters':[], 'commands':[]} - for pname, param in self.list_module_params(modulename, only_static=True).items(): + mod_desc = {'parameters': [], 'commands': []} + for pname, param in self.list_module_params( + modulename, only_static=True).items(): mod_desc['parameters'].extend([pname, param]) for cname, cmd in self.list_module_cmds(modulename).items(): mod_desc['commands'].extend([cname, cmd]) @@ -236,7 +237,9 @@ class Dispatcher(object): module = self.get_module(modulename) # some of these need rework ! dd = { - 'parameters': self.list_module_params(modulename, only_static=True), + 'parameters': self.list_module_params( + modulename, + only_static=True), 'commands': self.list_module_cmds(modulename), 'properties': module.PROPERTIES, } @@ -319,9 +322,9 @@ class Dispatcher(object): return Value( modulename, parameter=pname, - value=pobj.value, + value=pobj.export_value, t=pobj.timestamp) - return Value(modulename, parameter=pname, value=pobj.value) + return Value(modulename, parameter=pname, value=pobj.export_value) # now the (defined) handlers for the different requests def handle_Help(self, conn, msg): @@ -396,7 +399,7 @@ class Dispatcher(object): res = Value( module=modulename, parameter=pname, - value=pobj.value, + value=pobj.export_value, t=pobj.timestamp, unit=pobj.unit) if res.value != Ellipsis: # means we do not have a value at all so skip this diff --git a/secop/devices/__init__.py b/secop_demo/__init__.py similarity index 100% rename from secop/devices/__init__.py rename to secop_demo/__init__.py diff --git a/secop/devices/cryo.py b/secop_demo/cryo.py similarity index 98% rename from secop/devices/cryo.py rename to secop_demo/cryo.py index 790ecf7..47b36b6 100644 --- a/secop/devices/cryo.py +++ b/secop_demo/cryo.py @@ -25,7 +25,7 @@ import time import random import threading -from secop.devices.core import Driveable, CMD, PARAM +from secop.modules import Driveable, CMD, PARAM from secop.protocol import status from secop.datatypes import FloatRange, EnumType, TupleOf from secop.lib import clamp, mkthread @@ -66,15 +66,15 @@ class Cryostat(CryoBase): datatype=FloatRange(0), default=1, unit="W", readonly=False, group='heater_settings', - ), + ), heater=PARAM("current heater setting", datatype=FloatRange(0, 100), default=0, unit="%", group='heater_settings', - ), + ), heaterpower=PARAM("current heater power", datatype=FloatRange(0), default=0, unit="W", group='heater_settings', - ), + ), target=PARAM("target temperature", datatype=FloatRange(0), default=0, unit="K", readonly=False, @@ -112,21 +112,23 @@ class Cryostat(CryoBase): datatype=FloatRange(0, 100), default=0.1, unit='K', readonly=False, group='stability', - ), + ), window=PARAM("time window for stability checking", datatype=FloatRange(1, 900), default=30, unit='s', readonly=False, group='stability', - ), + ), timeout=PARAM("max waiting time for stabilisation check", datatype=FloatRange(1, 36000), default=900, unit='s', readonly=False, group='stability', - ), + ), ) CMDS = dict( - Stop=CMD("Stop ramping the setpoint\n\nby setting the current setpoint as new target", - [], None), + Stop=CMD( + "Stop ramping the setpoint\n\nby setting the current setpoint as new target", + [], + None), ) def init(self): diff --git a/secop/devices/demo.py b/secop_demo/modules.py similarity index 91% rename from secop/devices/demo.py rename to secop_demo/modules.py index da2d499..b565525 100644 --- a/secop/devices/demo.py +++ b/secop_demo/modules.py @@ -24,7 +24,7 @@ import time import random import threading -from secop.devices.core import Readable, Driveable, PARAM +from secop.modules import Readable, Driveable, PARAM from secop.datatypes import EnumType, FloatRange, IntRange, ArrayOf, StringType, TupleOf, StructOf, BoolType from secop.protocol import status @@ -287,18 +287,18 @@ class DatatypesTest(Readable): """ """ PARAMS = { - 'enum': PARAM('enum', - datatype=EnumType('boo', 'faar', z=9), readonly=False, default=1), - 'tupleof': PARAM('tuple of int, float and str', - datatype=TupleOf(IntRange(), FloatRange(), StringType()), readonly=False, default=(1, 2.3, 'a')), - 'arrayof': PARAM('array: 2..3 times bool', - datatype=ArrayOf(BoolType(), 2, 3), readonly=False, default=[1, 0, 1]), - 'intrange': PARAM('intrange', - datatype=IntRange(2, 9), readonly=False, default=4), - 'floatrange': PARAM('floatrange', - datatype=FloatRange(-1, 1), readonly=False, default=0, - ), - 'struct': PARAM('struct(a=str, b=int, c=bool)', - datatype=StructOf(a=StringType(), b=IntRange(), c=BoolType()), - ), - } + 'enum': PARAM( + 'enum', datatype=EnumType( + 'boo', 'faar', z=9), readonly=False, default=1), 'tupleof': PARAM( + 'tuple of int, float and str', datatype=TupleOf( + IntRange(), FloatRange(), StringType()), readonly=False, default=( + 1, 2.3, 'a')), 'arrayof': PARAM( + 'array: 2..3 times bool', datatype=ArrayOf( + BoolType(), 2, 3), readonly=False, default=[ + 1, 0, 1]), 'intrange': PARAM( + 'intrange', datatype=IntRange( + 2, 9), readonly=False, default=4), 'floatrange': PARAM( + 'floatrange', datatype=FloatRange( + -1, 1), readonly=False, default=0, ), 'struct': PARAM( + 'struct(a=str, b=int, c=bool)', datatype=StructOf( + a=StringType(), b=IntRange(), c=BoolType()), ), } diff --git a/secop/devices/test.py b/secop_demo/test.py similarity index 83% rename from secop/devices/test.py rename to secop_demo/test.py index 30278b4..0ff6ccc 100644 --- a/secop/devices/test.py +++ b/secop_demo/test.py @@ -22,9 +22,10 @@ import random -from secop.devices.core import Readable, Driveable, PARAM +from secop.modules import Readable, Driveable, PARAM from secop.datatypes import FloatRange, StringType + class LN2(Readable): """Just a readable. @@ -62,12 +63,20 @@ class Temp(Driveable): but the implementation may do anything """ PARAMS = { - 'sensor': PARAM("Sensor number or calibration id", - datatype=StringType(8,16), readonly=True, - ), - 'target': PARAM("Target temperature", - default=300.0, datatype=FloatRange(0), readonly=False, unit='K', - ), + 'sensor': PARAM( + "Sensor number or calibration id", + datatype=StringType( + 8, + 16), + readonly=True, + ), + 'target': PARAM( + "Target temperature", + default=300.0, + datatype=FloatRange(0), + readonly=False, + unit='K', + ), } def read_value(self, maxage=0): diff --git a/secop_ess/__init__.py b/secop_ess/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secop/devices/epics.py b/secop_ess/epics.py similarity index 80% rename from secop/devices/epics.py rename to secop_ess/epics.py index 4c1bd46..e73c444 100644 --- a/secop/devices/epics.py +++ b/secop_ess/epics.py @@ -24,7 +24,7 @@ import random from secop.lib.parsing import format_time from secop.datatypes import EnumType, TupleOf, FloatRange, get_datatype, StringType -from secop.devices.core import Readable, Device, Driveable, PARAM +from secop.modules import Readable, Device, Driveable, PARAM from secop.protocol import status try: @@ -62,18 +62,18 @@ class EpicsReadable(Readable): """EpicsDriveable handles a Driveable interfacing to EPICS v4""" # Commmon PARAMS for all EPICS devices PARAMS = { - 'value': PARAM('EPICS generic value', - datatype=FloatRange(), - default=300.0,), + 'value': PARAM('EPICS generic value', + datatype=FloatRange(), + default=300.0,), 'epics_version': PARAM("EPICS version used, v3 or v4", datatype=EnumType(v3=3, v4=4),), # 'private' parameters: not remotely accessible - 'value_pv': PARAM('EPICS pv_name of value', - datatype=StringType(), - default="unset", export=False), - 'status_pv': PARAM('EPICS pv_name of status', - datatype=StringType(), - default="unset", export=False), + 'value_pv': PARAM('EPICS pv_name of value', + datatype=StringType(), + default="unset", export=False), + 'status_pv': PARAM('EPICS pv_name of status', + datatype=StringType(), + default="unset", export=False), } # Generic read and write functions @@ -122,19 +122,19 @@ class EpicsDriveable(Driveable): """EpicsDriveable handles a Driveable interfacing to EPICS v4""" # Commmon PARAMS for all EPICS devices PARAMS = { - 'target': PARAM('EPICS generic target', datatype=FloatRange(), - default=300.0, readonly=False), - 'value': PARAM('EPICS generic value', datatype=FloatRange(), - default=300.0,), + 'target': PARAM('EPICS generic target', datatype=FloatRange(), + default=300.0, readonly=False), + 'value': PARAM('EPICS generic value', datatype=FloatRange(), + default=300.0,), 'epics_version': PARAM("EPICS version used, v3 or v4", datatype=StringType(),), # 'private' parameters: not remotely accessible - 'target_pv': PARAM('EPICS pv_name of target', datatype=StringType(), - default="unset", export=False), - 'value_pv': PARAM('EPICS pv_name of value', datatype=StringType(), - default="unset", export=False), - 'status_pv': PARAM('EPICS pv_name of status', datatype=StringType(), - default="unset", export=False), + 'target_pv': PARAM('EPICS pv_name of target', datatype=StringType(), + default="unset", export=False), + 'value_pv': PARAM('EPICS pv_name of value', datatype=StringType(), + default="unset", export=False), + 'status_pv': PARAM('EPICS pv_name of status', datatype=StringType(), + default="unset", export=False), } # Generic read and write functions @@ -182,8 +182,11 @@ class EpicsDriveable(Driveable): # XXX: how to map an unknown type+value to an valid status ??? return status.UNKNOWN, self._read_pv(self.status_pv) # status_pv is unset, derive status from equality of value + target - return (status.OK, '') if self.read_value() == self.read_target() else \ - (status.BUSY, 'Moving') + return ( + status.OK, + '') if self.read_value() == self.read_target() else ( + status.BUSY, + 'Moving') """Temperature control loop""" @@ -195,11 +198,11 @@ class EpicsTempCtrl(EpicsDriveable): PARAMS = { # TODO: restrict possible values with oneof datatype - 'heaterrange': PARAM('Heater range', datatype=StringType(), - default='Off', readonly=False,), - 'tolerance': PARAM('allowed deviation between value and target', - datatype=FloatRange(1e-6, 1e6), default=0.1, - readonly=False,), + 'heaterrange': PARAM('Heater range', datatype=StringType(), + default='Off', readonly=False,), + 'tolerance': PARAM('allowed deviation between value and target', + datatype=FloatRange(1e-6, 1e6), default=0.1, + readonly=False,), # 'private' parameters: not remotely accessible 'heaterrange_pv': PARAM('EPICS pv_name of heater range', datatype=StringType(), default="unset", export=False,), @@ -221,7 +224,11 @@ class EpicsTempCtrl(EpicsDriveable): # XXX: comparison may need to collect a history to detect oscillations at_target = abs(self.read_value(maxage) - self.read_target(maxage)) \ <= self.tolerance - return (status.OK, 'at Target') if at_target else (status.BUSY, 'Moving') + return ( + status.OK, + 'at Target') if at_target else ( + status.BUSY, + 'Moving') # TODO: add support for strings over epics pv # def read_heaterrange(self, maxage=0): diff --git a/secop_mlz/__init__.py b/secop_mlz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secop_mlz/entangle.py b/secop_mlz/entangle.py new file mode 100644 index 0000000..30f362b --- /dev/null +++ b/secop_mlz/entangle.py @@ -0,0 +1,1024 @@ +# -*- 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: +# Alexander Lenz +# Enrico Faulhaber +# +# ***************************************************************************** + +# This is based upon the entangle-nicos integration +""" +This module contains the MLZ SECoP - TANGO integration. + +Here we support devices which fulfill the official +MLZ TANGO interface for the respective device classes. +""" + +import re +import sys +from time import sleep, time as currenttime +import threading + +import PyTango +import numpy + +from secop.lib import lazy_property, mkthread +from secop.protocol import status +from secop.datatypes import * +from secop.errors import SECoPServerError, ConfigError, ProgrammingError, CommunicationError, HardwareError +from secop.modules import PARAM, CMD, OVERRIDE, Device, Readable, Driveable + + +# Only export these classes for 'from secop_mlz import *' +__all__ = [ + 'AnalogInput', 'Sensor', + 'AnalogOutput', 'Actuator', 'Motor', + 'TemperatureController', 'PowerSupply', + 'DigitalInput', 'NamedDigitalInput', 'PartialDigitalInput', + 'DigitalOutput', 'NamedDigitalOutput', 'PartialDigitalOutput', + 'StringIO', +] + +EXC_MAPPING = { + PyTango.CommunicationFailed: CommunicationError, + PyTango.WrongNameSyntax: ConfigError, + PyTango.DevFailed: HardwareError, +} + +REASON_MAPPING = { + 'Entangle_ConfigurationError': ConfigError, + 'Entangle_WrongAPICall': ProgrammingError, + 'Entangle_CommunicationFailure': CommunicationError, + 'Entangle_InvalidValue': ValueError, + 'Entangle_ProgrammingError': ProgrammingError, + 'Entangle_HardwareFailure': HardwareError, +} + +# Tango DevFailed reasons that should not cause a retry +FATAL_REASONS = set(( + 'Entangle_ConfigurationError', + 'Entangle_UnrecognizedHardware', + 'Entangle_WrongAPICall', + 'Entangle_InvalidValue', + 'Entangle_NotSupported', + 'Entangle_ProgrammingError', + 'DB_DeviceNotDefined', + 'API_DeviceNotDefined', + 'API_CantConnectToDatabase', + 'API_TangoHostNotSet', + 'API_ServerNotRunning', + 'API_DeviceNotExported', +)) + + +def describe_dev_error(exc): + """Return a better description for a Tango exception. + + Most Tango exceptions are quite verbose and not suitable for user + consumption. Map the most common ones, that can also happen during normal + operation, to a bit more friendly ones. + """ + # general attributes + reason = exc.reason.strip() + fulldesc = reason + ': ' + exc.desc.strip() + # reduce Python tracebacks + if '\n' in exc.origin and 'File ' in exc.origin: + origin = exc.origin.splitlines()[-2].strip() + else: + origin = exc.origin.strip() + + # we don't need origin info for Tango itself + if origin.startswith(('DeviceProxy::', 'DeviceImpl::', 'Device_3Impl::', + 'Device_4Impl::', 'Connection::', 'TangoMonitor::')): + origin = None + + # now handle specific cases better + if reason == 'API_AttrNotAllowed': + m = re.search(r'to (read|write) attribute (\w+)', fulldesc) + if m: + if m.group(1) == 'read': + fulldesc = 'reading %r not allowed in current state' + else: + fulldesc = 'writing %r not allowed in current state' + fulldesc %= m.group(2) + elif reason == 'API_CommandNotAllowed': + m = re.search(r'Command (\w+) not allowed when the ' + r'device is in (\w+) state', fulldesc) + if m: + fulldesc = 'executing %r not allowed in state %s' \ + % (m.group(1), m.group(2)) + elif reason == 'API_DeviceNotExported': + m = re.search(r'Device ([\w/]+) is not', fulldesc) + if m: + fulldesc = 'Tango device %s is not exported, is the server ' \ + 'running?' % m.group(1) + elif reason == 'API_CorbaException': + if 'TRANSIENT_CallTimedout' in fulldesc: + fulldesc = 'Tango client-server call timed out' + elif 'TRANSIENT_ConnectFailed' in fulldesc: + fulldesc = 'connection to Tango server failed, is the server ' \ + 'running?' + elif reason == 'API_CantConnectToDevice': + m = re.search(r'connect to device ([\w/]+)', fulldesc) + if m: + fulldesc = 'connection to Tango device %s failed, is the server ' \ + 'running?' % m.group(1) + elif reason == 'API_CommandTimedOut': + if 'acquire serialization' in fulldesc: + fulldesc = 'Tango call timed out waiting for lock on server' + + # append origin if wanted + if origin: + fulldesc += ' in %s' % origin + return fulldesc + + +class PyTangoDevice(Device): + """ + Basic PyTango device. + + The PyTangoDevice uses an internal PyTango.DeviceProxy but wraps command + execution and attribute operations with logging and exception mapping. + """ + + PARAMS = { + 'comtries': PARAM('Maximum retries for communication', + datatype=IntRange(1, 100), default=3, readonly=False, group='communication'), + 'comdelay': PARAM('Delay between retries', datatype=FloatRange(0), unit='s', default=0.1, + readonly=False, group='communication'), + + 'tangodevice': PARAM('Tango device name', + datatype=StringType(), readonly=True, +# export=True, # for testing only + export=False, + ), + } + + tango_status_mapping = { + PyTango.DevState.ON: status.OK, + PyTango.DevState.ALARM: status.WARN, + PyTango.DevState.OFF: status.ERROR, + PyTango.DevState.FAULT: status.ERROR, + PyTango.DevState.MOVING: status.BUSY, + } + + @lazy_property + def _com_lock(self): + return threading.Lock() + + def _com_retry(self, info, function, *args, **kwds): + """Try communicating with the hardware/device. + + PARAMeter "info" is passed to _com_return and _com_raise methods that + process the return value or exception raised after maximum tries. + """ + tries = self.comtries + with self._com_lock: + while True: + tries -= 1 + try: + result = function(*args, **kwds) + return self._com_return(result, info) + except Exception as err: + if tries == 0: + self._com_raise(err, info) + else: + name = getattr(function, '__name__', 'communication') + self._com_warn(tries, name, err, info) + sleep(self.comdelay) + + def init(self): + # Wrap PyTango client creation (so even for the ctor, logging and + # exception mapping is enabled). + self._createPyTangoDevice = self._applyGuardToFunc( + self._createPyTangoDevice, 'constructor') + super(PyTangoDevice, self).init() + + @lazy_property + def _dev(self): + return self._createPyTangoDevice(self.tangodevice) + + def _hw_wait(self): + """Wait until hardware status is not BUSY.""" + while PyTangoDevice.doStatus(self, 0)[0] == status.BUSY: + sleep(self._base_loop_delay) + + def _getProperty(self, name, dev=None): + """ + Utility function for getting a property by name easily. + """ + if dev is None: + dev = self._dev + # Entangle and later API + if dev.command_query('GetProperties').in_type == PyTango.DevVoid: + props = dev.GetProperties() + return props[props.index(name) + 1] if name in props else None + # old (pre-Entangle) API + return dev.GetProperties([name, 'device'])[2] + + def _createPyTangoDevice(self, address): # pylint: disable=E0202 + """ + Creates the PyTango DeviceProxy and wraps command execution and + attribute operations with logging and exception mapping. + """ + device = PyTango.DeviceProxy(address) + # detect not running and not exported devices early, because that + # otherwise would lead to attribute errors later + try: + device.State + except AttributeError: + raise CommunicationError( + self, 'connection to Tango server failed, ' + 'is the server running?') + return self._applyGuardsToPyTangoDevice(device) + + def _applyGuardsToPyTangoDevice(self, dev): + """ + Wraps command execution and attribute operations of the given + device with logging and exception mapping. + """ + dev.command_inout = self._applyGuardToFunc(dev.command_inout) + dev.write_attribute = self._applyGuardToFunc(dev.write_attribute, + 'attr_write') + dev.read_attribute = self._applyGuardToFunc(dev.read_attribute, + 'attr_read') + dev.attribute_query = self._applyGuardToFunc(dev.attribute_query, + 'attr_query') + return dev + + def _applyGuardToFunc(self, func, category='cmd'): + """ + Wrap given function with logging and exception mapping. + """ + def wrap(*args, **kwds): + # handle different types for better debug output + if category == 'cmd': + self.log.debug('[PyTango] command: %s%r', args[0], args[1:]) + elif category == 'attr_read': + self.log.debug('[PyTango] read attribute: %s', args[0]) + elif category == 'attr_write': + self.log.debug('[PyTango] write attribute: %s => %r', + args[0], args[1:]) + elif category == 'attr_query': + self.log.debug('[PyTango] query attribute properties: %s', + args[0]) + elif category == 'constructor': + self.log.debug('[PyTango] device creation: %s', args[0]) + elif category == 'internal': + self.log.debug('[PyTango integration] internal: %s%r', + func.__name__, args) + else: + self.log.debug('[PyTango] call: %s%r', func.__name__, args) + + info = category + ' ' + args[0] if args else category + return self._com_retry(info, func, *args, **kwds) + + # hide the wrapping + wrap.__name__ = func.__name__ + + return wrap + + def _com_return(self, result, info): + """Process *result*, the return value of communication. + + Can raise an exception to initiate a retry. Default is to return + result unchanged. + """ + # XXX: explicit check for loglevel to avoid expensive reprs + if isinstance(result, PyTango.DeviceAttribute): + the_repr = repr(result.value)[:300] + else: + # This line explicitly logs '=> None' for commands which + # does not return a value. This indicates that the command + # execution ended. + the_repr = repr(result)[:300] + self.log.debug('\t=> %s', the_repr) + return result + + def _tango_exc_desc(self, err): + exc = str(err) + if err.args: + exc = err.args[0] # Can be str or DevError + if isinstance(exc, PyTango.DevError): + return describe_dev_error(exc) + return exc + + def _tango_exc_reason(self, err): + if err.args and isinstance(err.args[0], PyTango.DevError): + return err.args[0].reason.strip() + return '' + + def _com_warn(self, retries, name, err, info): + """Gives the opportunity to warn the user on failed tries. + + Can also call _com_raise to abort early. + """ + if self._tango_exc_reason(err) in FATAL_REASONS: + self._com_raise(err, info) + if retries == self.comtries - 1: + self.log.warning('%s failed, retrying up to %d times: %s', + info, retries, self._tango_exc_desc(err)) + + def _com_raise(self, err, info): + """Process the exception raised either by communication or _com_return. + + Should raise a NICOS exception. Default is to raise + CommunicationError. + """ + reason = self._tango_exc_reason(err) + exclass = REASON_MAPPING.get( + reason, EXC_MAPPING.get(type(err), CommunicationError)) + fulldesc = self._tango_exc_desc(err) + self.log.debug('PyTango error: %s', fulldesc) + raise exclass(self, fulldesc) + + def read_status(self, maxage=0): + # Query status code and string + tangoState = self._dev.State() + tangoStatus = self._dev.Status() + + # Map status + myState = self.tango_status_mapping.get(tangoState, status.UNKNOWN) + + return (myState, tangoStatus) + + def do_reset(self): + self._dev.Reset() + + +class AnalogInput(PyTangoDevice, Readable): + """ + The AnalogInput handles all devices only delivering an analogue value. + """ + + def init(self): + super(AnalogInput, self).init() + # query unit from tango and update value property + attrInfo = self._dev.attribute_query('value') + # prefer configured unit if nothing is set on the Tango device, else + # update + if attrInfo.unit != 'No unit': + self.PARAMS['value'].unit = attrInfo.unit + + def read_value(self, maxage=0): + return self._dev.value + + +class Sensor(AnalogInput): + """ + The sensor interface describes all analog read only devices. + + The difference to AnalogInput is that the “value” attribute can be + converted from the “raw value” to a physical value with an offset and a + formula. + """ + # note: we don't transport the formula to secop.... + # we support the adjust method + + def do_setposition(self, value): + self._dev.Adjust(value) + + +class AnalogOutput(PyTangoDevice, Driveable): + """ + The AnalogOutput handles all devices which set an analogue value. + + The main application field is the output of any signal which may be + considered as continously in a range. The values may have nearly any + value between the limits. The compactness is limited by the resolution of + the hardware. + + This class should be considered as a base class for motors, temperature + controllers, ... + """ + + PARAMS = { + 'userlimits': PARAM( + 'User defined limits of device value', + unit='main', + datatype=TupleOf( + FloatRange(), + FloatRange()), + default=( + float('-Inf'), + float('+Inf')), + readonly=False, + poll=10), + 'abslimits': PARAM( + 'Absolute limits of device value', + unit='main', + datatype=TupleOf( + FloatRange(), + FloatRange()), + ), + 'precision': PARAM( + 'Precision of the device value (allowed deviation ' + 'of stable values from target)', + unit='main', + datatype=FloatRange(1e-38), + readonly=False, + ), + 'window': PARAM( + 'Time window for checking stabilization if > 0', + unit='s', + default=60.0, + datatype=FloatRange( + 0, + 900), + readonly=False, + ), + 'pollinterval': PARAM( + '[min, max] sleeptime between polls', + default=[ + 0.5, + 5], + readonly=False, + datatype=TupleOf( + FloatRange( + 0, + 20), + FloatRange( + 0.1, + 120)), + ), + } + OVERRIDES = { + 'value': OVERRIDE(poll=False), + } + + def init(self): + super(AnalogInput, self).init() + # query unit from tango and update value property + attrInfo = self._dev.attribute_query('value') + # prefer configured unit if nothing is set on the Tango device, else + # update + if attrInfo.unit != 'No unit': + self.PARAMS['value'].unit = attrInfo.unit + + # init history + self._history = [] # will keep (timestamp, value) tuple + mkthread(self._history_thread) + + def _history_thread(self): + while True: + # adaptive sleeping interval + if self.status[0] == status.BUSY: + sleep(min(self.pollinterval)) + else: + sleep(min(max(self.pollinterval) / 2., + max(self.window / 10., min(pollinterval)))) + try: + self.read_value(0) # also append to self._history + # shorten history + while len(self._history) > 2: + # if history would be too short, break + if self._history[-1][0] - \ + self._history[1][0] < self.window: + break + # remove a stale point + self._history.pop(0) + except Exception: + pass + + def read_value(self, maxage=0): + value = self._dev.value + self._history.append((currenttime(), value)) + return value + + def read_target(self, maxage=0): + attrObj = self._dev.read_attribute('value') + return attrObj.w_value + + def _isAtTarget(self): + if self.target is None: + return True # avoid bootstrapping problems + # check subset of _history which is in window + # also check if there is at least one value before window + # to know we have enough datapoints + hist = self._history[:] + window_start = currenttime() - self.window + hist_in_window = [v for (t, v) in hist if t >= window_start] + stable = all(abs(v - self.target) <= self.precision + for v in hist_in_window) + return 0 < len(hist_in_window) < len(hist) and stable + + @property + def absmin(self): + return self.abslimits[0] + + @property + def absmax(self): + return self.abslimits[1] + + def __getusermin(self): + return self.userlimits[0] + + def __setusermin(self, value): + self.userlimits = (value, self.userlimits[1]) + + usermin = property(__getusermin, __setusermin) + + def __getusermax(self): + return self.userlimits[1] + + def __setusermax(self, value): + self.userlimits = (self.userlimits[0], value) + + usermax = property(__getusermax, __setusermax) + + del __getusermin, __setusermin, __getusermax, __setusermax + + def _checkLimits(self, limits): + umin, umax = limits + amin, amax = self.abslimits + if umin > umax: + raise ValueError( + self, 'user minimum (%s) above the user ' + 'maximum (%s)' % (umin, umax)) + if umin < amin - abs(amin * 1e-12): + umin = amin + if umax > amax + abs(amax * 1e-12): + umax = amax + return (umin, umax) + + def write_userlimits(self, value): + return self._checkLimits(value) + + def do_start(self, value=FloatRange()): + try: + self._dev.value = value + except HardwareError: + # changing target value during movement is not allowed by the + # Tango base class state machine. If we are moving, stop first. + if self.read_status(0)[0] == status.BUSY: + self.stop() + self._hw_wait() + self._dev.value = value + else: + raise + + def do_stop(self): + self._dev.Stop() + + +class Actuator(AnalogOutput): + """ + The actuator interface describes all analog devices which DO something in a + defined way. + + The difference to AnalogOutput is that there is a speed attribute, and the + value attribute is converted from the “raw value” with a formula and + offset. + """ + # for secop: support the speed and ramp parameters + + PARAMS = { + 'speed': PARAM( + 'The speed of changing the value', + unit='main/s', + readonly=False, + datatype=FloatRange(0)), + 'ramp': PARAM( + 'The speed of changing the value', + unit='main/min', + readonly=False, + datatype=FloatRange(0), + poll=30), + } + + def read_speed(self): + return self._dev.speed + + def write_speed(self, value): + self._dev.speed = value + + def read_ramp(self): + return self.read_speed() * 60 + + def write_ramp(self, value): + self.write_speed(value / 60.) + return self.speed * 60 + + def do_setposition(self, value): + self._dev.Adjust(value) + + +class Motor(Actuator): + """ + This class implements a motor device (in a sense of a real motor + (stepper motor, servo motor, ...)). + + It has the ability to move a real object from one place to another place. + """ + + PARAMS = { + 'refpos': PARAM( + 'Reference position', + datatype=FloatRange(), + unit='main'), + 'accel': PARAM( + 'Acceleration', + datatype=FloatRange(), + readonly=False, + unit='main/s^2'), + 'decel': PARAM( + 'Deceleration', + datatype=FloatRange(), + readonly=False, + unit='main/s^2'), + } + + def read_refpos(self): + return float(self._getProperty('refpos')) + + def read_accel(self): + return self._dev.accel + + def write_accel(self, value): + self._dev.accel = value + + def read_decel(self): + return self._dev.decel + + def write_decel(self, value): + self._dev.decel = value + + def do_reference(self): + self._dev.Reference() + return self.read_value() + + +class TemperatureController(Actuator): + """ + A temperature control loop device. + """ + + PARAMS = { + 'p': PARAM('Proportional control PARAMeter', datatype=FloatRange(), + readonly=False, group='pid', + ), + 'i': PARAM('Integral control PARAMeter', datatype=FloatRange(), + readonly=False, group='pid', + ), + 'd': PARAM('Derivative control PARAMeter', datatype=FloatRange(), + readonly=False, group='pid', + ), + 'pid': PARAM('pid control PARAMeters', datatype=TupleOf(FloatRange(), FloatRange(), FloatRange()), + readonly=False, group='pid', poll=30, + ), + 'setpoint': PARAM('Current setpoint', datatype=FloatRange(), poll=1, + ), + 'heateroutput': PARAM('Heater output', datatype=FloatRange(), poll=1, + ), + 'ramp': PARAM('Temperature ramp', unit='main/min', + datatype=FloatRange(), readonly=False, poll=30), + } + + OVERRIDES = { + # We want this to be freely user-settable, and not produce a warning + # on startup, so select a usually sensible default. + 'precision': OVERRIDE(default=0.1), + } + + def read_ramp(self): + return self._dev.ramp + + def write_ramp(self, value): + self._dev.ramp = value + return self._dev.ramp + + def read_p(self): + return self._dev.p + + def write_p(self, value): + self._dev.p = value + + def read_i(self): + return self._dev.i + + def write_i(self, value): + self._dev.i = value + + def read_d(self): + return self._dev.d + + def write_d(self, value): + self._dev.d = value + + def read_pid(self): + self.read_p() + self.read_i() + self.read_d() + return self.p, self.i, self.d + + def write_pid(self, value): + self._dev.p = value[0] + self._dev.i = value[1] + self._dev.d = value[2] + + def read_setpoint(self): + return self._dev.setpoint + + def read_heateroutput(self): + return self._dev.heaterOutput + + +class PowerSupply(Actuator): + """ + A power supply (voltage and current) device. + """ + + PARAMS = { + 'ramp': PARAM('Current/voltage ramp', unit='main/min', + datatype=FloatRange(), readonly=False, poll=30,), + 'voltage': PARAM('Actual voltage', unit='V', + datatype=FloatRange(), poll=5), + 'current': PARAM('Actual current', unit='A', + datatype=FloatRange(), poll=5), + } + + def read_ramp(self): + return self._dev.ramp + + def write_ramp(self, value): + self._dev.ramp = value + + def read_voltage(self): + return self._dev.voltage + + def read_current(self): + return self._dev.current + + +class DigitalInput(PyTangoDevice, Readable): + """ + A device reading a bitfield. + """ + + OVERRIDES = { + 'value': OVERRIDE(datatype=IntRange(0)), + } + + def read_value(self, maxage=0): + return self._dev.value + + +class NamedDigitalInput(DigitalInput): + """ + A DigitalInput with numeric values mapped to names. + """ + + PARAMS = { + 'mapping': PARAM('A dictionary mapping state names to integers', + datatype=StringType(), export=False), # XXX:!!! + } + + def init(self): + super(NamedDigitalInput, self).init() + try: + self.PARAMS['value'].datatype = EnumType(**eval(self.mapping)) + except Exception as e: + raise ValueError('Illegal Value for mapping: %r' % e) + + def read_value(self, maxage=0): + value = self._dev.value + return value # mapping is done by datatype upon export() + + +class PartialDigitalInput(NamedDigitalInput): + """ + Base class for a TANGO DigitalInput with only a part of the full + bit width accessed. + """ + + PARAMS = { + 'startbit': PARAM( + 'Number of the first bit', + datatype=IntRange(0), + default=0), + 'bitwidth': PARAM( + 'Number of bits', + datatype=IntRange(0), + default=1), + } + + def init(self): + super(PartialDigitalInput, self).init() + self._mask = (1 << self.bitwidth) - 1 + #self.PARAMS['value'].datatype = IntRange(0, self._mask) + + def read_value(self, maxage=0): + raw_value = self._dev.value + value = (raw_value >> self.startbit) & self._mask + return value # mapping is done by datatype upon export() + + +class DigitalOutput(PyTangoDevice, Driveable): + """ + A devices that can set and read a digital value corresponding to a + bitfield. + """ + + OVERRIDES = { + 'value': OVERRIDE(datatype=IntRange(0)), + 'target': OVERRIDE(datatype=IntRange(0)), + } + + def read_value(self, maxage=0): + return self._dev.value # mapping is done by datatype upon export() + + def write_target(self, value): + self._dev.value = value + self.read_value() + + def read_target(self, maxage=0): + attrObj = self._dev.read_attribute('value') + return attrObj.w_value + + +class NamedDigitalOutput(DigitalOutput): + """ + A DigitalOutput with numeric values mapped to names. + """ + + PARAMS = { + 'mapping': PARAM('A dictionary mapping state names to integers', + datatype=StringType(), export=False), # XXX: !!! + } + + def init(self): + super(NamedDigitalOutput, self).init() + try: # XXX: !!! + self.PARAMS['value'].datatype = EnumType(**eval(self.mapping)) + except Exception as e: + raise ValueError('Illegal Value for mapping: %r' % e) + + +class PartialDigitalOutput(NamedDigitalOutput): + """ + Base class for a TANGO DigitalOutput with only a part of the full + bit width accessed. + """ + + PARAMS = { + 'startbit': PARAM( + 'Number of the first bit', + datatype=IntRange(0), + default=0), + 'bitwidth': PARAM( + 'Number of bits', + datatype=IntRange(0), + default=1), + } + + def init(self, mode): + super(PartialDigitalOutput, self).init() + self._mask = (1 << self.bitwidth) - 1 + #self.PARAMS['value'].datatype = IntRange(0, self._mask) + #self.PARAMS['target'].datatype = IntRange(0, self._mask) + + def read_value(self, maxage=0): + raw_value = self._dev.value + value = (raw_value >> self.startbit) & self._mask + return value # mapping is done by datatype upon export() + + def write_target(self, target): + curvalue = self._dev.value + newvalue = (curvalue & ~(self._mask << self.startbit)) | \ + (target << self.startbit) + self._dev.value = newvalue + self.read_value() + + +class StringIO(PyTangoDevice, Device): + """ + StringIO abstracts communication over a hardware bus that sends and + receives strings. + """ + + PARAMS = { + 'bustimeout': PARAM( + 'Communication timeout', + datatype=FloatRange(), + readonly=False, + unit='s', + group='communication'), + 'endofline': PARAM( + 'End of line', + datatype=StringType(), + readonly=False, + group='communication'), + 'startofline': PARAM( + 'Start of line', + datatype=StringType(), + readonly=False, + group='communication'), + } + + def read_bustimeout(self): + return self._dev.communicationTimeout + + def write_bustimeout(self, value): + self._dev.communicationTimeout = value + + def read_endofline(self): + return self._dev.endOfLine + + def write_endofline(self, value): + self._dev.endOfLine = value + + def read_startofline(self): + return self._dev.startOfLine + + def write_startofline(self, value): + self._dev.startOfLine = value + + CMDS = { + 'communicate': CMD( + 'Send a string and return the reply', + arguments=[ + StringType()], + result=StringType()), + 'flush': CMD( + 'Flush output buffer', + arguments=[], + result=None), + 'read': CMD( + 'read some characters from input buffer', + arguments=[ + IntRange()], + result=StringType()), + 'write': CMD( + 'write some chars to output', + arguments=[ + StringType()], + result=None), + 'readLine': CMD( + 'Read sol - a whole line - eol', + arguments=[], + result=StringType()), + 'writeLine': CMD( + 'write sol + a whole line + eol', + arguments=[ + StringType()], + result=None), + 'availablechars': CMD( + 'return number of chars in input buffer', + arguments=[], + result=IntRange(0)), + 'availablelines': CMD( + 'return number of lines in input buffer', + arguments=[], + result=IntRange(0)), + 'multicommunicate': CMD( + 'perform a sequence of communications', + arguments=[ + ArrayOf( + TupleOf( + StringType(), + IntRange()))], + result=ArrayOf( + StringType())), + } + + def do_communicate(self, value=StringType()): + return self._dev.Communicate(value) + + def do_flush(self): + self._dev.Flush() + + def do_read(self, value): + return self._dev.Read(value) + + def do_write(self, value): + return self._dev.Write(value) + + def do_readLine(self): + return self._dev.ReadLine() + + def do_writeLine(self, value): + return self._dev.WriteLine(value) + + def do_multiCommunicate(self, value): + return self._dev.MultiCommunicate(value) + + def do_availablechars(self): + return self._dev.availableChars + + def do_availablelines(self): + return self._dev.availableLines diff --git a/test/test_client_baseclient.py b/test/test_client_baseclient.py new file mode 100644 index 0000000..ea746d6 --- /dev/null +++ b/test/test_client_baseclient.py @@ -0,0 +1,83 @@ +# -*- 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 +# +# ***************************************************************************** +"""test base client.""" + +import pytest + +import sys +sys.path.insert(0, sys.path[0]+'/..') + +from collections import OrderedDict +from secop.client.baseclient import Client + +# define Test-only connection object +class TestConnect(object): + callbacks = [] + def writeline(self, line): + pass + + def readline(self): + return '' + + +@pytest.fixture(scope="module") +def clientobj(request): + print (" SETUP ClientObj") + testconnect = TestConnect() + yield Client(dict(testing=testconnect), autoconnect=False) + for cb, arg in testconnect.callbacks: + cb(arg) + print (" TEARDOWN ClientObj") + + +def test_describing_data_decode(clientobj): + assert OrderedDict([('a',1)]) == clientobj._decode_list_to_ordereddict(['a',1]) + assert {'modules':{}, 'properties':{}} == clientobj._decode_substruct(['modules'],{}) + describing_data = {'equipment_id': 'eid', + 'modules': ['LN2', {'commands': [], + 'interfaces': ['Readable', 'Device'], + 'parameters': ['value', {'datatype': ['double'], + 'description': 'current value', + 'readonly': True, + } + ] + } + ] + } + decoded_data = {'modules': {'LN2': {'commands': {}, + 'parameters': {'value': {'datatype': ['double'], + 'description': 'current value', + 'readonly': True, + } + }, + 'properties': {'interfaces': ['Readable', 'Device']} + } + }, + 'properties': {'equipment_id': 'eid', + } + } + + a = clientobj._decode_substruct(['modules'], describing_data) + for modname, module in a['modules'].items(): + a['modules'][modname] = clientobj._decode_substruct(['parameters', 'commands'], module) + assert a == decoded_data +