frappy/secop/client/baseclient.py
Enrico Faulhaber 5458911b67 Adapt sim_* cfg's to current syntax
+ make 'limit' usable as type in cfg files
+ minor fixes

Change-Id: Ib94b2645c7a0d978d64d4c86c4415d4b5b0d485f
Reviewed-on: https://forge.frm2.tum.de/review/c/sine2020/secop/playground/+/21485
Tested-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
Tested-by: JenkinsCodeReview <bjoern_pedersen@frm2.tum.de>
Reviewed-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
2019-10-31 13:59:56 +01:00

585 lines
21 KiB
Python

# -*- coding: utf-8 -*-
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
#
# *****************************************************************************
"""Define Client side proxies"""
import json
import queue
import socket
import threading
import time
from collections import OrderedDict
from select import select
try:
import mlzlog
except ImportError:
pass
import serial
from secop.datatypes import CommandType, EnumType, get_datatype
from secop.errors import EXCEPTIONS
from secop.lib import formatException, formatExtendedStack, mkthread
from secop.lib.parsing import format_time, parse_time
from secop.protocol.messages import BUFFERREQUEST, COMMANDREQUEST, \
DESCRIPTIONREPLY, DESCRIPTIONREQUEST, DISABLEEVENTSREQUEST, \
ENABLEEVENTSREQUEST, ERRORPREFIX, EVENTREPLY, \
HEARTBEATREQUEST, HELPREQUEST, IDENTREQUEST, READREPLY, \
READREQUEST, REQUEST2REPLY, WRITEREPLY, WRITEREQUEST
class TCPConnection:
# disguise a TCP connection as serial one
def __init__(self, host, port, getLogger=None):
if getLogger:
self.log = getLogger('TCPConnection')
else:
self.log = mlzlog.getLogger('TCPConnection')
self._host = host
self._port = int(port)
self._thread = None
self.callbacks = [] # called if SEC-node shuts down
self._io = None
self.connect()
def connect(self):
self._readbuffer = queue.Queue(100)
time.sleep(1)
io = socket.create_connection((self._host, self._port))
io.setblocking(False)
self.stopflag = False
self._io = io
if self._thread and self._thread.is_alive():
return
self._thread = mkthread(self._run)
def _run(self):
try:
data = b''
while not self.stopflag:
rlist, _, xlist = select([self._io], [], [self._io], 1)
if xlist:
# on some strange systems, a closed connection is indicated by
# an exceptional condition instead of "read ready" + "empty recv"
newdata = b''
else:
if not rlist:
continue # check stopflag every second
# self._io is now ready to read some bytes
try:
newdata = self._io.recv(1024)
except socket.error as err:
if err.args[0] == socket.EAGAIN:
# if we receive an EAGAIN error, just continue
continue
newdata = b''
except Exception:
newdata = b''
if not newdata: # no data on recv indicates a closed connection
raise IOError('%s:%d disconnected' % (self._host, self._port))
lines = (data + newdata).split(b'\n')
for line in lines[:-1]: # last line is incomplete or empty
try:
self._readbuffer.put(line.strip(b'\r').decode('utf-8'),
block=True, timeout=1)
except queue.Full:
self.log.debug('rcv queue full! dropping line: %r' % line)
data = lines[-1]
except Exception as err:
self.log.error(err)
try:
self._io.shutdown(socket.SHUT_RDWR)
except socket.error:
pass
try:
self._io.close()
except socket.error:
pass
for cb, args in self.callbacks:
cb(*args)
def readline(self, timeout=None):
"""blocks until a full line was read and returns it
returns None when connection is stopped"""
if self.stopflag:
return None
return self._readbuffer.get(block=True, timeout=timeout)
def stop(self):
self.stopflag = True
self._readbuffer.put(None) # terminate pending readline
def readable(self):
return not self._readbuffer.empty()
def write(self, data):
if self._io is None:
self.connect()
self._io.sendall(data.encode('latin-1'))
def writeline(self, line):
self.write(line + '\n')
def writelines(self, *lines):
for line in lines:
self.writeline(line)
class Value:
t = None # pylint: disable = C0103
u = None
e = None
fmtstr = '%s'
def __init__(self, value, qualifiers=None):
self.value = value
if qualifiers:
self.__dict__.update(qualifiers)
if 't' in qualifiers:
try:
self.t = float(qualifiers['t'])
except Exception:
self.t = parse_time(qualifiers['t'])
def __repr__(self):
r = []
if self.t is not None:
r.append("timestamp=%r" % format_time(self.t))
if self.u is not None:
r.append('unit=%r' % self.u)
if self.e is not None:
r.append(('error=%s' % self.fmtstr) % self.e)
if r:
return (self.fmtstr + '(%s)') % (self.value, ', '.join(r))
return self.fmtstr % self.value
class Client:
secop_id = 'unknown'
describing_data = {}
stopflag = False
connection_established = False
def __init__(self, opts, autoconnect=True, getLogger=None):
if 'testing' not in opts:
if getLogger:
self.log = getLogger('client')
else:
self.log = mlzlog.getLogger('client', True)
else:
class logStub:
def info(self, *args):
pass
debug = info
error = info
warning = info
exception = info
self.log = logStub()
self._cache = dict()
if 'module' in opts:
# serial port
devport = opts.pop('module')
baudrate = int(opts.pop('baudrate', 115200))
self.contactPoint = "serial://%s:%s" % (devport, baudrate)
self.connection = serial.Serial(
devport, baudrate=baudrate, timeout=1)
self.connection.callbacks = []
elif 'testing' not in opts:
host = opts.pop('host', 'localhost')
port = int(opts.pop('port', 10767))
self.contactPoint = "tcp://%s:%d" % (host, port)
self.connection = TCPConnection(host, port, getLogger=getLogger)
else:
self.contactPoint = 'testing'
self.connection = opts.pop('testing')
# maps an expected reply to a list containing a single Event()
# upon rcv of that reply, entry is appended with False and
# the data of the reply.
# if an error is received, the entry is appended with True and an
# appropriate Exception.
# Then the Event is set.
self.expected_replies = {}
# maps spec to a set of callback functions (or single_shot callbacks)
self.callbacks = dict()
self.single_shots = dict()
# mapping the modulename to a dict mapping the parameter names to their values
# note: the module value is stored as the value of the parameter value
# of the module
self._syncLock = threading.RLock()
self._thread = threading.Thread(target=self._run)
self._thread.daemon = True
self._thread.start()
if autoconnect:
self.startup()
def _run(self):
while not self.stopflag:
try:
self._inner_run()
except Exception as err:
print(formatExtendedStack())
self.log.exception(err)
raise
def _inner_run(self):
data = ''
self.connection.writeline('*IDN?')
while not self.stopflag:
line = self.connection.readline()
if line is None: # connection stopped
break
self.connection_established = True
self.log.debug('got answer %r' % line)
if line.startswith(('SECoP', 'SINE2020&ISSE,SECoP')):
self.log.info('connected to: ' + line.strip())
self.secop_id = line
continue
msgtype, spec, data = self.decode_message(line)
if msgtype in (EVENTREPLY, READREPLY, WRITEREPLY):
# handle async stuff
self._handle_event(spec, data)
# handle sync stuff
self._handle_sync_reply(msgtype, spec, data)
def _handle_sync_reply(self, msgtype, spec, data):
# handle sync stuff
if msgtype.startswith(ERRORPREFIX):
# find originating msgtype and map to expected_reply_type
# errormessages carry to offending request as the first
# result in the resultist
request = msgtype[len(ERRORPREFIX):]
reply = REQUEST2REPLY.get(request, request)
entry = self.expected_replies.get((reply, spec), None)
if entry:
self.log.error("request %r resulted in Error %r" %
("%s %s" % (request, spec), (data[0], data[1])))
entry.extend([True, EXCEPTIONS[data[0]](*data[1:])])
entry[0].set()
return
self.log.error("got an unexpected %s %r" % (msgtype,data[0:1]))
self.log.error(repr(data))
return
if msgtype == DESCRIPTIONREPLY:
entry = self.expected_replies.get((msgtype, ''), None)
else:
entry = self.expected_replies.get((msgtype, spec), None)
if entry:
self.log.debug("got expected reply '%s %s'" % (msgtype, spec)
if spec else "got expected reply '%s'" % msgtype)
entry.extend([False, msgtype, spec, data])
entry[0].set()
def encode_message(self, requesttype, spec='', data=None):
"""encodes the given message to a string
"""
req = [str(requesttype)]
if spec:
req.append(str(spec))
if data is not None:
req.append(json.dumps(data))
req = ' '.join(req)
return req
def decode_message(self, msg):
"""return a decoded message triple"""
msg = msg.strip()
if ' ' not in msg:
return msg, '', None
msgtype, spec = msg.split(' ', 1)
data = None
if ' ' in spec:
spec, json_data = spec.split(' ', 1)
try:
data = json.loads(json_data)
except ValueError:
# keep as string
data = json_data
# print formatException()
return msgtype, spec, data
def _handle_event(self, spec, data):
"""handles event"""
# self.log.debug('handle_event %r %r' % (spec, data))
if ':' not in spec:
self.log.warning("deprecated specifier %r" % spec)
spec = '%s:value' % spec
modname, pname = spec.split(':', 1)
if data:
self._cache.setdefault(modname, {})[pname] = Value(*data)
else:
self.log.warning(
'got malformed answer! (%s,%s)' % (spec, data))
# self.log.info('cache: %s:%s=%r (was: %s)', modname, pname, data, previous)
if spec in self.callbacks:
for func in self.callbacks[spec]:
try:
mkthread(func, modname, pname, data)
except Exception as err:
self.log.exception('Exception in Callback!', err)
run = set()
if spec in self.single_shots:
for func in self.single_shots[spec]:
try:
mkthread(func, data)
except Exception as err:
self.log.exception('Exception in Single-shot Callback!',
err)
run.add(func)
self.single_shots[spec].difference_update(run)
def _getDescribingModuleData(self, module):
return self.describingModulesData[module]
def _getDescribingParameterData(self, module, parameter):
return self._getDescribingModuleData(module)['accessibles'][parameter]
def _decode_substruct(self, specialkeys=[], data={}): # pylint: disable=W0102
# take a dict and move all keys which are not in specialkeys
# into a 'properties' subdict
# specialkeys entries are converted from list to ordereddict
try:
result = {}
for k in specialkeys:
result[k] = OrderedDict(data.pop(k, []))
result['properties'] = data
return result
except Exception as err:
raise RuntimeError('Error decoding substruct of descriptive data: %r\n%r' % (err, data))
def _issueDescribe(self):
_, _, describing_data = self._communicate(DESCRIPTIONREQUEST)
try:
describing_data = self._decode_substruct(
['modules'], describing_data)
for modname, module in list(describing_data['modules'].items()):
# convert old namings of interface_classes
if 'interface_class' in module:
module['interface_classes'] = module.pop('interface_class')
elif 'interfaces' in module:
module['interface_classes'] = module.pop('interfaces')
describing_data['modules'][modname] = self._decode_substruct(
['accessibles'], module)
self.describing_data = describing_data
for module, moduleData in self.describing_data['modules'].items():
for aname, adata in moduleData['accessibles'].items():
datatype = get_datatype(adata.pop('datainfo'))
# *sigh* special handling for 'some' parameters....
if isinstance(datatype, EnumType):
datatype._enum.name = aname
if aname == 'status':
datatype.members[0]._enum.name = 'Status'
self.describing_data['modules'][module]['accessibles'] \
[aname]['datatype'] = datatype
except Exception as _exc:
print(formatException(verbose=True))
raise
def register_callback(self, module, parameter, cb):
self.log.debug('registering callback %r for %s:%s' %
(cb, module, parameter))
self.callbacks.setdefault('%s:%s' % (module, parameter), set()).add(cb)
def unregister_callback(self, module, parameter, cb):
self.log.debug('unregistering callback %r for %s:%s' %
(cb, module, parameter))
self.callbacks.setdefault('%s:%s' % (module, parameter),
set()).discard(cb)
def register_shutdown_callback(self, func, *args):
self.connection.callbacks.append((func, args))
def communicate(self, msgtype, spec='', data=None):
# only return the data portion....
return self._communicate(msgtype, spec, data)[2]
def _communicate(self, msgtype, spec='', data=None):
self.log.debug('communicate: %r %r %r' % (msgtype, spec, data))
if self.stopflag:
raise RuntimeError('alreading stopping!')
if msgtype == IDENTREQUEST:
return self.secop_id
# sanitize input
msgtype = str(msgtype)
spec = str(spec)
if msgtype not in (DESCRIPTIONREQUEST, ENABLEEVENTSREQUEST,
DISABLEEVENTSREQUEST, COMMANDREQUEST,
WRITEREQUEST, BUFFERREQUEST,
READREQUEST, HEARTBEATREQUEST, HELPREQUEST):
raise EXCEPTIONS['Protocol'](args=[
self.encode_message(msgtype, spec, data),
dict(
errorclass='Protocol',
errorinfo='%r: No Such Messagetype defined!' % msgtype, ),
])
# handle syntactic sugar
if msgtype == WRITEREQUEST and ':' not in spec:
spec = spec + ':target'
if msgtype == READREQUEST and ':' not in spec:
spec = spec + ':value'
# check if such a request is already out
rply = REQUEST2REPLY[msgtype]
if (rply, spec) in self.expected_replies:
raise RuntimeError(
"can not have more than one requests of the same type at the same time!"
)
# prepare sending request
event = threading.Event()
self.expected_replies[(rply, spec)] = [event]
self.log.debug('prepared reception of %r msg' % rply)
# send request
msg = self.encode_message(msgtype, spec, data)
while not self.connection_established:
self.log.debug('connection not established yet, waiting ...')
time.sleep(0.1)
self.connection.writeline(msg)
self.log.debug('sent msg %r' % msg)
# wait for reply. timeout after 10s
if event.wait(10):
self.log.debug('checking reply')
entry = self.expected_replies.pop((rply, spec))
# entry is: event, is_error, exc_or_msgtype [,spec, date]<- if !err
is_error = entry[1]
if is_error:
# if error, entry[2] contains the rigth Exception to raise
raise entry[2]
# valid reply: entry[2:5] contain msgtype, spec, data
return tuple(entry[2:5])
# timed out
del self.expected_replies[(rply, spec)]
# XXX: raise a TimedOut ?
raise RuntimeError("timeout upon waiting for reply to %r!" % msgtype)
def quit(self):
# after calling this the client is dysfunctional!
# self.communicate(DISABLEEVENTSREQUEST)
self.stopflag = True
self.connection.stop()
if self._thread and self._thread.is_alive():
self._thread.join(10)
def startup(self, _async=False):
self._issueDescribe()
# always fill our cache
self.communicate(ENABLEEVENTSREQUEST)
# deactivate updates if not wanted
if not _async:
self.communicate(DISABLEEVENTSREQUEST)
def queryCache(self, module, parameter=None):
result = self._cache.get(module, {})
if parameter is not None:
result = result[parameter]
return result
def getParameter(self, module, parameter):
return self.communicate(READREQUEST, '%s:%s' % (module, parameter))
def setParameter(self, module, parameter, value):
datatype = self._getDescribingParameterData(module,
parameter)['datatype']
value = datatype.from_string(value)
value = datatype.export_value(value)
self.communicate(WRITEREQUEST, '%s:%s' % (module, parameter), value)
@property
def describingData(self):
return self.describing_data
@property
def describingModulesData(self):
return self.describingData['modules']
@property
def equipmentId(self):
if self.describingData:
return self.describingData['properties']['equipment_id']
return 'Undetermined'
@property
def protocolVersion(self):
return self.secop_id
@property
def modules(self):
return list(self.describing_data['modules'].keys())
def getParameters(self, module):
params = filter(lambda item: not isinstance(item[1]['datatype'], CommandType),
self.describing_data['modules'][module]['accessibles'].items())
return list(param[0] for param in params)
def getModuleProperties(self, module):
return self.describing_data['modules'][module]['properties']
def getModuleBaseClass(self, module):
return self.getModuleProperties(module)['interface_classes']
def getCommands(self, module):
cmds = filter(lambda item: isinstance(item[1]['datatype'], CommandType),
self.describing_data['modules'][module]['accessibles'].items())
return OrderedDict(cmds)
def execCommand(self, module, command, args):
# ignore reply message + reply specifier, only return data
return self._communicate(COMMANDREQUEST, '%s:%s' % (module, command), list(args) if args else None)[2]
def getProperties(self, module, parameter):
return self.describing_data['modules'][module]['accessibles'][parameter]
def syncCommunicate(self, *msg):
res = self._communicate(*msg) # pylint: disable=E1120
try:
res = self.encode_message(*res)
except Exception:
res = str(res)
return res
def ping(self, pingctr=[0]): # pylint: disable=W0102
pingctr[0] = pingctr[0] + 1
self.communicate(HEARTBEATREQUEST, pingctr[0])