frappy/secop_psi/sea.py
Markus Zolliker 48230334af fix inheritance problem with mixin
- a mixin should not inherit from module then it has Parameters
- Parameters in mixins must be complete, not just overrides
- check precedence of read_<param> or handler

Change-Id: I72d9355a1982770d1a99d9552a20330103c97edb
2021-03-18 13:32:54 +01:00

493 lines
18 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# *****************************************************************************
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""generic SEA driver
a object or subobject in sea may be assigned to a SECoP module
Examples:
SECoP SEA hipadaba path mod.obj mod.sub par.sub mod.path
-------------------------------------------------------------------------------
tt:maxwait tt /tt/maxwait tt maxwait /tt
tt:ramp tt set/ramp /tt/set/ramp tt set/ramp /tt
t1:raw tt t1/raw /tt/t1/raw tt t1 raw /tt/t1
rx:bla rx bla /some/rx_a/bla rx bla /some/rx_a
"""
import json
import threading
import time
import os
from os.path import expanduser, join, exists
from secop.client import ProxyClient
from secop.datatypes import ArrayOf, BoolType, \
EnumType, FloatRange, IntRange, StringType
from secop.errors import ConfigError, HardwareError, secop_error
from secop.lib import getGeneralConfig, mkthread
from secop.lib.asynconn import AsynConn, ConnectionClosed
from secop.modules import Attached, Command, Done, Drivable, \
Module, Parameter, Readable, Writable
from secop.protocol.dispatcher import make_update
CFG_HEADER = """[NODE]
id = %(samenv)s.psi.ch
description = %(samenv)s over SEA
[seaconn]
class = secop_psi.sea.SeaClient
description = a SEA connection
"""
CFG_MODULE = """
[%(module)s]
class = secop_psi.sea.%(modcls)s
iodev = seaconn
json_descr = %(descr)s
remote_paths = .
"""
SEA_DIR = expanduser('~/sea')
for confdir in getGeneralConfig()['confdir'].split(os.pathsep):
seaconfdir = join(confdir, 'sea')
if exists(seaconfdir):
break
else:
seaconfdir = None
def get_sea_port(instance):
for filename in ('sea_%s.tcl' % instance, 'sea.tcl'):
try:
with open(join(SEA_DIR, filename)) as f:
for line in f:
linesplit = line.split()
if len(linesplit) == 3:
_, var, value = line.split()
if var == 'serverport':
return value
except FileNotFoundError:
pass
return None
class SeaClient(ProxyClient, Module):
"""connection to SEA"""
uri = Parameter('hostname:portnumber', datatype=StringType(), default='localhost:5000')
timeout = Parameter('timeout', datatype=FloatRange(0), default=10)
def __init__(self, name, log, opts, srv):
instance = srv.node_cfg['name'].rsplit('_', 1)[0]
if 'uri' not in opts:
port = get_sea_port(instance)
if port is None:
raise ConfigError('missing sea port for %s' % instance)
opts['uri'] = 'tcp://localhost:%s' % port
self.objects = []
self.shutdown = False
self.path2param = {}
self._write_lock = threading.Lock()
self.io = None
ProxyClient.__init__(self)
Module.__init__(self, name, log, opts, srv)
def register_obj(self, module, obj):
self.objects.append(obj)
self.path2param.update(module.path2param)
self.register_callback(module.name, module.updateEvent)
def startModule(self, started_callback):
mkthread(self._connect, started_callback)
def _connect(self, started_callback):
if '//' not in self.uri:
self.uri = 'tcp://' + self.uri
self.asyncio = AsynConn(self.uri)
assert self.asyncio.readline() == b'OK'
self.asyncio.writeline(b'Spy 007')
assert self.asyncio.readline() == b'Login OK'
# the json protocol is better for updates
self.asyncio.writeline(b'protocol set json')
self.asyncio.writeline(('get_all_param ' + ' '.join(self.objects)).encode())
mkthread(self._rxthread, started_callback)
def request(self, command):
"""send a request and wait for reply"""
with self._write_lock:
if not self.io or not self.io.connection:
if not self.asyncio.connection:
self._connect(None)
self.io = AsynConn(self.uri)
assert self.io.readline() == b'OK'
self.io.writeline(b'seauser seaser')
assert self.io.readline() == b'Login OK'
self.io.flush_recv()
self.io.writeline(('fulltransact %s' % command).encode())
result = None
deadline = time.time() + 10
while time.time() < deadline:
try:
reply = self.io.readline()
if reply is None:
continue
except ConnectionClosed:
break
reply = reply.decode()
if reply.startswith('TRANSACTIONSTART'):
result = []
continue
if reply == 'TRANSACTIONFINISHED':
if result is None:
print('missing TRANSACTIONSTART on: %s' % command)
return ''
if not result:
return ''
return '\n'.join(result)
if result is None:
print('swallow: %s' % reply)
continue
if not result:
result = [reply.split('=', 1)[-1]]
else:
result.append(reply)
raise TimeoutError('no response within 10s')
def _rxthread(self, started_callback):
while not self.shutdown:
try:
reply = self.asyncio.readline()
if reply is None:
continue
except ConnectionClosed:
break
try:
msg = json.loads(reply)
except Exception as e:
print(repr(e), reply)
continue
if isinstance(msg, str):
if msg.startswith('_E '):
try:
_, path, readerror = msg.split(None, 2)
except ValueError:
continue
else:
continue
data = {'%s.geterror' % path: readerror.replace('ERROR: ', '')}
obj = None
flag = 'hdbevent'
else:
obj = msg['object']
flag = msg['flag']
data = msg['data']
if flag == 'finish' and obj == 'get_all_param':
# first updates have finished
if started_callback:
started_callback()
started_callback = None
continue
if flag != 'hdbevent':
if obj != 'protocol':
print('SKIP', msg)
continue
if data is None:
continue
now = time.time()
for path, value in data.items():
readerror = None
if path.endswith('.geterror'):
if value:
readerror = HardwareError(value)
path = path.rsplit('.', 1)[0]
value = None
try:
module, param = self.path2param[path]
except KeyError:
# print('UNUSED', msg)
continue # unused parameter
oldv, oldt, oldr = self.cache.get((module, param), [None, None, None])
if value is None:
value = oldv
if value != oldv or str(readerror) != str(oldr) or abs(now - oldt) > 60:
# do not update unchanged values within 0.1 sec
self.updateValue(module, param, value, now, readerror)
@Command
def communicate(self, command):
"""send a command to SEA"""
reply = self.request(command)
return reply
@Command(result=StringType())
def describe(self):
"""save objects (and sub-objects) description"""
reply = self.request('describe_all')
reply = ''.join('' if line.startswith('WARNING') else line for line in reply.split('\n'))
samenv, reply = json.loads(reply)
samenv = samenv.replace('/', '_')
result = []
with open(join(seaconfdir, samenv + '.cfg'), 'w') as cfp:
cfp.write(CFG_HEADER % dict(samenv=samenv))
for filename, obj, descr in reply:
content = json.dumps([obj, descr]).replace('}, {', '},\n{')
with open(join(seaconfdir, filename + '.json'), 'w') as fp:
fp.write(content + '\n')
if descr[0].get('cmd', '').startswith('run '):
modcls = 'SeaDrivable'
else:
modcls = 'SeaReadable'
cfp.write(CFG_MODULE % dict(modcls=modcls, module=obj, descr=filename))
result.append(filename)
return '\n'.join(result)
SEA_TO_SECOPTYPE = {
'float': FloatRange(),
'text': StringType(),
'int': IntRange(),
'bool': BoolType(),
'none': None,
'floatvarar': ArrayOf(FloatRange(), 0, 400), # 400 is the current limit for proper notify events in SEA
}
def get_datatype(paramdesc):
typ = paramdesc['type']
result = SEA_TO_SECOPTYPE.get(typ, False)
if result is not False: # general case
return result
# special cases
if typ == 'enum':
return EnumType(paramdesc['enum'])
raise ValueError('unknown SEA type %r' % typ)
class SeaModule(Module):
iodev = Attached()
# pollerClass=None
path2param = None
sea_object = None
def __new__(cls, name, logger, cfgdict, dispatcher):
visibility_level = cfgdict.pop('visibility_level', 2)
json_descr = cfgdict.pop('json_descr')
remote_paths = cfgdict.pop('remote_paths', '')
if 'description' not in cfgdict:
cfgdict['description'] = '%s (remote_paths=%s)' % (json_descr, remote_paths)
with open(join(seaconfdir, json_descr + '.json')) as fp:
sea_object, descr = json.load(fp)
remote_paths = remote_paths.split()
if remote_paths:
result = []
for rpath in remote_paths:
include = True
for paramdesc in descr:
path = paramdesc['path']
if paramdesc.get('visibility', 1) > visibility_level:
if not path.endswith('is_running'):
continue
sub = path.split('/', 1)
if rpath == '.': # take all except subpaths with readonly node at top
if len(sub) == 1:
include = paramdesc.get('kids', 0) == 0 or not paramdesc.get('readonly', True)
if include or path == '':
result.append(paramdesc)
elif sub[0] == rpath:
result.append(paramdesc)
descr = result
main = remote_paths[0]
if main == '.':
main = ''
else: # take all
main = ''
path2param = {}
attributes = dict(sea_object=sea_object, path2param=path2param)
for paramdesc in descr:
path = paramdesc['path']
readonly = paramdesc.get('readonly', True)
dt = get_datatype(paramdesc)
kwds = dict(description=paramdesc.get('description', path),
datatype=dt,
visibility=paramdesc.get('visibility', 1),
needscfg=False, poll=False, readonly=readonly)
if kwds['datatype'] is None:
kwds.update(visibility=3, default='', datatype=StringType())
pathlist = path.split('/') if path else []
if path == main:
key = 'value'
else:
if len(pathlist) > 0:
if len(pathlist) == 1:
kwds['group'] = 'more'
else:
kwds['group'] = pathlist[-2]
# flatten path to parameter name
key = None
for i in reversed(range(len(pathlist))):
key = '_'.join(pathlist[i:])
if not key in cls.accessibles:
break
if key == 'is_running':
kwds['export'] = False
path2param['/'.join(['', sea_object] + pathlist)] = (name, key)
if key in cls.accessibles:
if key == 'target':
kwds['readonly'] = False
pobj = cls.accessibles[key].override(**kwds)
datatype = kwds.get('datatype', cls.accessibles[key].datatype)
else:
pobj = Parameter(**kwds)
datatype = pobj.datatype
if name == 'cc' and key == 'value':
print('cc.value: %r %r' % (kwds, pobj))
attributes[key] = pobj
if not hasattr(cls, 'read_' + key):
def rfunc(self, cmd='hval /sics/%s/%s' % (sea_object, path)):
print('READ', cmd)
reply = self._iodev.request(cmd)
print('REPLY', reply)
if reply.startswith('ERROR: '):
raise HardwareError(reply.split(' ', 1)[1])
try:
reply = float(reply)
except ValueError:
pass
# an updateEvent will be handled before above returns
return reply
attributes['read_' + key] = rfunc
if not (readonly or hasattr(cls, 'write_' + key)):
# pylint wrongly complains 'Cell variable pobj defined in loop'
# pylint: disable=cell-var-from-loop
def wfunc(self, value, datatype=datatype, command=paramdesc['cmd']):
# TODO: convert to valid tcl data
cmd = "%s %s" % (command, datatype.export_value(value))
print('WRITE', cmd)
self._iodev.request(cmd)
# an updateEvent will be handled before above returns
return Done
attributes['write_' + key] = wfunc
# create standard parameters like value and status, if not yet there
for pname, pobj in cls.accessibles.items():
if pname == 'pollinterval':
attributes[pname] = pobj.override(export=False)
elif pname not in attributes and isinstance(pobj, Parameter):
attributes[pname] = pobj.override(poll=False, needscfg=False)
classname = '%s_%s' % (cls.__name__, sea_object)
newcls = type(classname, (cls,), attributes)
return Module.__new__(newcls)
# def __init__(self, name, logger, cfgdict, dispatcher):
# Module.__init__(self, name, logger, cfgdict, dispatcher)
def updateEvent(self, module, parameter, value, timestamp, readerror):
upd = getattr(self, 'update_' + parameter, None)
if upd:
upd(value, timestamp, readerror)
return
pobj = self.parameters[parameter]
pobj.timestamp = timestamp
#if not pobj.readonly and pobj.value != value:
# print('UPDATE', module, parameter, value)
# should be done here: deal with clock differences
if not readerror:
try:
pobj.value = value # store the value even in case of a validation error
pobj.value = pobj.datatype(value)
except Exception as e:
readerror = secop_error(e)
pobj.readerror = readerror
self.DISPATCHER.broadcast_event(make_update(self.name, pobj))
#def earlyInit(self):
# self.path2param = {k % subst: v for k, v in self.path2param.items()}
def initModule(self):
self._iodev.register_obj(self, self.sea_object)
super().initModule()
class SeaReadable(SeaModule, Readable):
def update_status(self, value, timestamp, readerror):
if readerror:
value = repr(readerror)
if value == '':
self.status = (self.Status.IDLE, '')
else:
self.status = (self.Status.ERROR, value)
def read_status(self):
return self.status
class SeaWritable(SeaModule, Writable):
pass
class SeaDrivable(SeaModule, Drivable):
_sea_status = ''
_is_running = 0
#def buildParams(self, cfgdict, name):
# # insert here special treatment for status and target
# super().buildParams(cfgdict)
def read_status(self):
return self.status
def read_target(self):
return self.target
def write_target(self, value):
self._iodev.request('run %s %s' % (self.sea_object, value))
#self.status = [self.Status.BUSY, 'driving']
return value
def update_status(self, value, timestamp, readerror):
if not readerror:
self._sea_status = value
self.updateStatus()
def update_is_running(self, value, timestamp, readerror):
if not readerror:
self._is_running = value
self.updateStatus()
def updateStatus(self):
if self._sea_status:
self.status = (self.Status.ERROR, self._sea_status)
elif self._is_running:
self.status = (self.Status.BUSY, 'driving')
else:
self.status = (self.Status.IDLE, '')
def updateTarget(self, module, parameter, value, timestamp, readerror):
if value is not None:
self.target = value