diff --git a/cfg/addons/stickmotor_cfg.py b/cfg/addons/stickmotor_cfg.py index cbf7d0a..9586a8d 100644 --- a/cfg/addons/stickmotor_cfg.py +++ b/cfg/addons/stickmotor_cfg.py @@ -13,4 +13,5 @@ Mod('stickrot', 'stick rotation, typically not used as omega', io='stick_io', encoder_mode='CHECK', + backlash=-1, ) diff --git a/cfg/main/ma10_cfg.py b/cfg/main/ma10_cfg.py index c77768a..844ab52 100644 --- a/cfg/main/ma10_cfg.py +++ b/cfg/main/ma10_cfg.py @@ -43,6 +43,7 @@ Mod('mf', 'frappy_psi.sea.SeaDrivable', '', io='sea_main', sea_object='mf', + rel_paths=['.', 'gen', 'ips'], ) Mod('lev', diff --git a/cfg/sea/hvolt_short.stick.json b/cfg/sea/hvolt_short.stick.json index 48c403e..9a36236 100644 --- a/cfg/sea/hvolt_short.stick.json +++ b/cfg/sea/hvolt_short.stick.json @@ -1,5 +1,5 @@ {"hcp": {"base": "/hcp", "params": [ -{"path": "", "type": "float", "kids": 10}, +{"path": "", "type": "float", "readonly": false, "cmd": "hcp set", "kids": 10}, {"path": "send", "type": "text", "readonly": false, "cmd": "hcp send", "visibility": 3}, {"path": "status", "type": "text", "visibility": 3}, {"path": "set", "type": "float", "readonly": false, "cmd": "hcp set"}, diff --git a/cfg/stick/hvolt_short_cfg.py b/cfg/stick/hvolt_short_cfg.py index 8256e83..de1bc93 100644 --- a/cfg/stick/hvolt_short_cfg.py +++ b/cfg/stick/hvolt_short_cfg.py @@ -18,7 +18,7 @@ Mod('ts', ) Mod('hcp', - 'frappy_psi.sea.SeaReadable', '', + 'frappy_psi.sea.SeaWritable', '', io='sea_stick', sea_object='hcp', ) diff --git a/doc/source/tutorial_t_control.rst b/doc/source/tutorial_t_control.rst index 420d294..786e7a4 100644 --- a/doc/source/tutorial_t_control.rst +++ b/doc/source/tutorial_t_control.rst @@ -200,7 +200,10 @@ implemented the readable stuff. We need to define some properties of the ``targe parameter and add a property ``loop`` indicating, which control loop and heater output we use. -In addition, we have to implement the methods ``write_target`` and ``read_target``: +In addition, we have to implement the method ``write_target``. Remark: we do not +implement ``read_target`` here, because the lakeshore does not offer to read back the +real target. The SETP command is returning the working setpoint, which may be distinct +from target during a ramp. .. code:: python @@ -216,11 +219,9 @@ In addition, we have to implement the methods ``write_target`` and ``read_target def write_target(self, target): # we always use a request / reply scheme - reply = self.communicate(f'SETP {self.loop},{target};SETP?{self.loop}') - return float(reply) + self.communicate(f'SETP {self.loop},{target};*OPC?') + return target - def read_target(self): - return float(self.communicate(f'SETP?{self.loop}')) In order to test this, we will need to change the entry module ``T`` in the configuration file: @@ -272,13 +273,12 @@ There are two things still missing: def write_target(self, target): # reactivate heater in case it was switched off self.communicate(f'RANGE {self.loop},{self.heater_range};RANGE?{self.loop}') - reply = self.communicate(f'SETP {self.loop},{target};SETP? {self.loop}') + self.communicate(f'SETP {self.loop},{target};*OPC?') self._driving = True # Setting the status attribute triggers an update message for the SECoP status # parameter. This has to be done before returning from this method! self.status = BUSY, 'target changed' - return float(reply) - + return target ... def read_status(self): @@ -403,4 +403,10 @@ Appendix 2: Extract from the LakeShore Manual Command (340) RANGE? *term* Command (336/350) RANGE? *term* Reply *term* + **Operation Complete Query** + ---------------------------------------------- + Command *OPC? + Reply 1 + Description in Frappy, we append this command to request in order + to generate a reply ====================== ======================= diff --git a/frappy/client/__init__.py b/frappy/client/__init__.py index 70f512b..0d4ddfc 100644 --- a/frappy/client/__init__.py +++ b/frappy/client/__init__.py @@ -261,7 +261,6 @@ class SecopClient(ProxyClient): """a general SECoP client""" reconnect_timeout = 10 _running = False - _shutdown = False _rxthread = None _txthread = None _connthread = None @@ -283,6 +282,7 @@ class SecopClient(ProxyClient): self.uri = uri self.nodename = uri self._lock = RLock() + self._shutdown = Event() def __del__(self): try: @@ -303,7 +303,7 @@ class SecopClient(ProxyClient): else: self._set_state(False, 'connecting') deadline = time.time() + try_period - while not self._shutdown: + while not self._shutdown.is_set(): try: self.io = AsynConn(self.uri) # timeout 1 sec self.io.writeline(IDENTREQUEST.encode('utf-8')) @@ -339,8 +339,8 @@ class SecopClient(ProxyClient): # stay online for now, if activated self._set_state(self.online and self.activate) raise - time.sleep(1) - if not self._shutdown: + self._shutdown.wait(1) + if not self._shutdown.is_set(): self.log.info('%s ready', self.nodename) def __txthread(self): @@ -436,7 +436,7 @@ class SecopClient(ProxyClient): self.log.error('rxthread ended with %r', e) self._rxthread = None self.disconnect(False) - if self._shutdown: + if self._shutdown.is_set(): return if self.activate: self.log.info('try to reconnect to %s', self.uri) @@ -454,7 +454,7 @@ class SecopClient(ProxyClient): self._connthread = mkthread(self._reconnect, connected_callback) def _reconnect(self, connected_callback=None): - while not self._shutdown: + while not self._shutdown.is_set(): try: self.connect() if connected_callback: @@ -474,15 +474,15 @@ class SecopClient(ProxyClient): self.log.info('continue trying to reconnect') # self.log.warning(formatExtendedTraceback()) self._set_state(False) - time.sleep(self.reconnect_timeout) + self._shutdown.wait(self.reconnect_timeout) else: - time.sleep(1) + self._shutdown.wait(1) self._connthread = None def disconnect(self, shutdown=True): self._running = False if shutdown: - self._shutdown = True + self._shutdown.set() self._set_state(False, 'shutdown') if self._connthread: if self._connthread == current_thread(): diff --git a/frappy/datatypes.py b/frappy/datatypes.py index c93de9f..5abf176 100644 --- a/frappy/datatypes.py +++ b/frappy/datatypes.py @@ -191,8 +191,8 @@ class HasUnit: class FloatRange(HasUnit, DataType): """(restricted) float type - :param minval: (property **min**) - :param maxval: (property **max**) + :param min: (property **min**) + :param max: (property **max**) :param kwds: any of the properties below """ min = Property('low limit', Stub('FloatRange'), extname='min', default=-sys.float_info.max) @@ -203,11 +203,11 @@ class FloatRange(HasUnit, DataType): relative_resolution = Property('relative resolution', Stub('FloatRange', 0), extname='relative_resolution', default=1.2e-7) - def __init__(self, minval=None, maxval=None, **kwds): + def __init__(self, min=None, max=None, **kwds): # pylint: disable=redefined-builtin super().__init__() - kwds['min'] = minval if minval is not None else -sys.float_info.max - kwds['max'] = maxval if maxval is not None else sys.float_info.max - self.set_properties(**kwds) + self.set_properties(min=min if min is not None else -sys.float_info.max, + max=max if max is not None else sys.float_info.max, + **kwds) def checkProperties(self): self.default = 0 if self.min <= 0 <= self.max else self.min @@ -247,9 +247,9 @@ class FloatRange(HasUnit, DataType): def __repr__(self): hints = self.get_info() if 'min' in hints: - hints['minval'] = hints.pop('min') + hints['min'] = hints.pop('min') if 'max' in hints: - hints['maxval'] = hints.pop('max') + hints['max'] = hints.pop('max') return 'FloatRange(%s)' % (', '.join('%s=%r' % (k, v) for k, v in hints.items())) def export_value(self, value): @@ -281,18 +281,18 @@ class FloatRange(HasUnit, DataType): class IntRange(DataType): """restricted int type - :param minval: (property **min**) - :param maxval: (property **max**) + :param min: (property **min**) + :param max: (property **max**) """ min = Property('minimum value', Stub('IntRange', -UNLIMITED, UNLIMITED), extname='min', mandatory=True) max = Property('maximum value', Stub('IntRange', -UNLIMITED, UNLIMITED), extname='max', mandatory=True) # a unit on an int is now allowed in SECoP, but do we need them in Frappy? # unit = Property('physical unit', StringType(), extname='unit', default='') - def __init__(self, minval=None, maxval=None): + def __init__(self, min=None, max=None): # pylint: disable=redefined-builtin super().__init__() - self.set_properties(min=DEFAULT_MIN_INT if minval is None else minval, - max=DEFAULT_MAX_INT if maxval is None else maxval) + self.set_properties(min=DEFAULT_MIN_INT if min is None else min, + max=DEFAULT_MAX_INT if max is None else max) def checkProperties(self): self.default = 0 if self.min <= 0 <= self.max else self.min @@ -364,8 +364,8 @@ class IntRange(DataType): class ScaledInteger(HasUnit, DataType): """scaled integer (= fixed resolution float) type - :param minval: (property **min**) - :param maxval: (property **max**) + :param min: (property **min**) + :param max: (property **max**) :param kwds: any of the properties below note: limits are for the scaled float value @@ -380,7 +380,8 @@ class ScaledInteger(HasUnit, DataType): relative_resolution = Property('relative resolution', FloatRange(0), extname='relative_resolution', default=1.2e-7) - def __init__(self, scale, minval=None, maxval=None, absolute_resolution=None, **kwds): + # pylint: disable=redefined-builtin + def __init__(self, scale, min=None, max=None, absolute_resolution=None, **kwds): super().__init__() try: scale = float(scale) @@ -390,8 +391,8 @@ class ScaledInteger(HasUnit, DataType): absolute_resolution = scale self.set_properties( scale=scale, - min=DEFAULT_MIN_INT * scale if minval is None else float(minval), - max=DEFAULT_MAX_INT * scale if maxval is None else float(maxval), + min=DEFAULT_MIN_INT * scale if min is None else float(min), + max=DEFAULT_MAX_INT * scale if max is None else float(max), absolute_resolution=absolute_resolution, **kwds) @@ -1327,11 +1328,11 @@ DATATYPES = { 'bool': lambda **kwds: BoolType(), 'int': lambda min, max, **kwds: - IntRange(minval=min, maxval=max), + IntRange(min=min, max=max), 'scaled': lambda scale, min, max, **kwds: - ScaledInteger(scale=scale, minval=min*scale, maxval=max*scale, **floatargs(kwds)), + ScaledInteger(scale=scale, min=min*scale, max=max*scale, **floatargs(kwds)), 'double': lambda min=None, max=None, **kwds: - FloatRange(minval=min, maxval=max, **floatargs(kwds)), + FloatRange(min=min, max=max, **floatargs(kwds)), 'blob': lambda maxbytes, minbytes=0, **kwds: BLOBType(minbytes=minbytes, maxbytes=maxbytes), 'string': lambda minchars=0, maxchars=None, isUTF8=False, **kwds: diff --git a/frappy/io.py b/frappy/io.py index 53de4bd..5a59d7c 100644 --- a/frappy/io.py +++ b/frappy/io.py @@ -63,6 +63,7 @@ class HasIO(Module): io = self.ioClass(ioname, srv.log.getChild(ioname), opts, srv) # pylint: disable=not-callable io.callingModule = [] srv.modules[ioname] = io + srv.dispatcher.register_module(io, ioname) self.ioDict[self.uri] = ioname self.io = ioname diff --git a/frappy/playground.py b/frappy/playground.py index 07061f6..05647e7 100644 --- a/frappy/playground.py +++ b/frappy/playground.py @@ -30,10 +30,10 @@ Remarks: import sys from logging import DEBUG, INFO, addLevelName import mlzlog -from frappy.errors import NoSuchModuleError from frappy.server import Server from frappy.config import load_config, Mod as ConfigMod from frappy.lib import generalConfig +from frappy.protocol import dispatcher USAGE = """create config on the fly: @@ -77,27 +77,25 @@ class MainLogger: self.log.handlers[0].setLevel(LOG_LEVELS['comlog']) -class Dispatcher: - def __init__(self, name, log, opts, srv): - self.log = log - self._modules = {} - self.equipment_id = opts.pop('equipment_id', name) +class Dispatcher(dispatcher.Dispatcher): + def __init__(self, name, log, options, srv): + super().__init__(name, log, options, srv) + self.log = srv.log # overwrite child logger def announce_update(self, modulename, pname, pobj): if pobj.readerror: value = repr(pobj.readerror) else: value = pobj.value - self.log.info('%s:%s %r', modulename, pname, value) + logobj = self._modules.get(modulename, self) + # self.log.info('%s:%s %r', modulename, pname, value) + logobj.log.info('%s %r', pname, value) def register_module(self, moduleobj, modulename, export=True): + self.log.info('registering %s', modulename) + super().register_module(moduleobj, modulename, export) setattr(main, modulename, moduleobj) - self._modules[modulename] = moduleobj - - def get_module(self, modulename): - if modulename in self._modules: - return self._modules[modulename] - raise NoSuchModuleError(f'Module {modulename!r} does not exist on this SEC-Node!') + self.get_module(modulename) logger = MainLogger() diff --git a/frappy/simulation.py b/frappy/simulation.py index 6c7be02..6e3de00 100644 --- a/frappy/simulation.py +++ b/frappy/simulation.py @@ -54,7 +54,7 @@ class SimBase: attrs['write_' + k] = writer - return object.__new__(type(f'SimBase_{devname}', (cls,), attrs)) + return super().__new__(type(f'SimBase_{devname}', (cls,), attrs)) def initModule(self): super().initModule() diff --git a/frappy_demo/lakeshore.py b/frappy_demo/lakeshore.py index c0442bc..a363712 100644 --- a/frappy_demo/lakeshore.py +++ b/frappy_demo/lakeshore.py @@ -69,24 +69,21 @@ class TemperatureSensor(HasIO, Readable): class TemperatureLoop(TemperatureSensor, Drivable): # lakeshore loop number to be used for this module loop = Property('lakeshore loop', IntRange(1, 2), default=1) - target = Parameter(datatype=FloatRange(min=0, max=1500, unit='K')) + target = Parameter(datatype=FloatRange(unit='K', min=0, max=1500)) heater_range = Property('heater power range', IntRange(0, 5)) # max. 3 on LakeShore 336 - tolerance = Parameter('convergence criterion', FloatRange(0), default=0.1, readonly = False) + tolerance = Parameter('convergence criterion', FloatRange(0), default=0.1, readonly=False) _driving = False def write_target(self, target): # reactivate heater in case it was switched off # the command has to be changed in case of model 340 to f'RANGE {self.heater_range};RANGE?' self.communicate(f'RANGE {self.loop},{self.heater_range};RANGE?{self.loop}') - reply = self.communicate(f'SETP {self.loop},{target};SETP? {self.loop}') + self.communicate(f'SETP {self.loop},{target};*OPC?') self._driving = True # Setting the status attribute triggers an update message for the SECoP status # parameter. This has to be done before returning from this method! self.status = BUSY, 'target changed' - return float(reply) - - def read_target(self): - return float(self.communicate(f'SETP?{self.loop}')) + return target def read_status(self): code = int(self.communicate(f'RDGST?{self.channel}')) diff --git a/frappy_psi/parmod.py b/frappy_psi/parmod.py index 58c3b3f..990a472 100644 --- a/frappy_psi/parmod.py +++ b/frappy_psi/parmod.py @@ -23,12 +23,36 @@ """modules to access parameters""" from frappy.core import Drivable, IDLE, Attached, StringType, Property, \ - Parameter, FloatRange + Parameter, FloatRange, Readable from frappy.errors import ConfigError from frappy_psi.convergence import HasConvergence from frappy_psi.mixins import HasRamp +class Par(Readable): + value = Parameter(datatype=FloatRange(unit='$')) + read = Attached(description='. for read') + unit = Property('main unit', StringType()) + + def setProperty(self, key, value): + if key == 'read': + value, param = value.split('.') + setattr(self, f'{key}_param', param) + super().setProperty(key, value) + + def checkProperties(self): + self.applyMainUnit(self.unit) + if self.read == self.name : + raise ConfigError('illegal recursive read/write module') + super().checkProperties() + + def read_value(self): + return getattr(self.read, f'{self.read_param}') + + def read_status(self): + return IDLE, '' + + class Driv(Drivable): value = Parameter(datatype=FloatRange(unit='$')) target = Parameter(datatype=FloatRange(unit='$')) diff --git a/frappy_psi/sea.py b/frappy_psi/sea.py index 7e17549..9906505 100644 --- a/frappy_psi/sea.py +++ b/frappy_psi/sea.py @@ -1,4 +1,3 @@ -# -*- 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 @@ -185,8 +184,9 @@ class SeaClient(ProxyClient, Module): break else: raise CommunicationFailedError('reply %r should be "Login OK"' % reply) - self.request('frappy_config %s %s' % (self.service, self.config)) - + result = self.request('frappy_config %s %s' % (self.service, self.config)) + if result not in {'0', '1'}: + raise CommunicationFailedError(f'reply from frappy_config: {result}') # frappy_async_client switches to the json protocol (better for updates) self.asynio.writeline(b'frappy_async_client') self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode()) @@ -248,7 +248,16 @@ class SeaClient(ProxyClient, Module): raise TimeoutError('no response within 10s') def _rxthread(self, started_callback): + recheck = None while not self.shutdown: + if recheck and time.time() > recheck: + # try to collect device changes within 1 sec + recheck = None + result = self.request('check_config %s %s' % (self.service, self.config)) + if result == '1': + self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode()) + else: + self.DISPATCHER.shutdown() try: reply = self.asynio.readline() if reply is None: @@ -307,11 +316,7 @@ class SeaClient(ProxyClient, Module): if mplist is None: if path.startswith('/device'): if path == '/device/changetime': - result = self.request('check_config %s %s' % (self.service, self.config)) - if result == '1': - self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode()) - else: - self.DISPATCHER.shutdown() + recheck = time.time() + 1 elif path.startswith('/device/frappy_%s' % self.service) and value == '': self.DISPATCHER.shutdown() else: diff --git a/test/test_datatypes.py b/test/test_datatypes.py index a4b2c1b..5c4c88b 100644 --- a/test/test_datatypes.py +++ b/test/test_datatypes.py @@ -160,9 +160,9 @@ def test_ScaledInteger(): with pytest.raises(ProgrammingError): ScaledInteger('xc', 'Yx') with pytest.raises(ProgrammingError): - ScaledInteger(scale=0, minval=1, maxval=2) + ScaledInteger(scale=0, min=1, max=2) with pytest.raises(ProgrammingError): - ScaledInteger(scale=-10, minval=1, maxval=2) + ScaledInteger(scale=-10, min=1, max=2) # check that unit can be changed dt.setProperty('unit', 'A') assert dt.export_datatype() == {'type': 'scaled', 'scale':0.01, 'min':-300, 'max':300, @@ -563,7 +563,7 @@ def test_get_datatype(): assert isinstance(get_datatype( {'type': 'scaled', 'scale':0.03, 'min':-99, 'max':111}), ScaledInteger) - dt = ScaledInteger(scale=0.03, minval=0, maxval=9.9) + dt = ScaledInteger(scale=0.03, min=0, max=9.9) assert dt.export_datatype() == {'type': 'scaled', 'max':330, 'min':0, 'scale':0.03} assert get_datatype(dt.export_datatype()).export_datatype() == dt.export_datatype()