Rename from secop to frappy
debian/ is still missing, will follow in next commit. Fixes: #4626 Change-Id: Ia87c28c1c75b8402eedbfca47f888585a7881f44
This commit is contained in:
committed by
Enrico Faulhaber
parent
c1eb764b09
commit
7f166a5b8c
587
frappy/client/__init__.py
Normal file
587
frappy/client/__init__.py
Normal file
@@ -0,0 +1,587 @@
|
||||
# -*- 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>
|
||||
# Markus Zolliker <markus.zolliker@psi.ch>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""general SECoP client"""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from threading import Event, RLock, current_thread
|
||||
|
||||
import frappy.errors
|
||||
import frappy.params
|
||||
from frappy.datatypes import get_datatype
|
||||
from frappy.lib import mkthread
|
||||
from frappy.lib.asynconn import AsynConn, ConnectionClosed
|
||||
from frappy.protocol.interface import decode_msg, encode_msg_frame
|
||||
from frappy.protocol.messages import COMMANDREQUEST, \
|
||||
DESCRIPTIONREQUEST, ENABLEEVENTSREQUEST, ERRORPREFIX, \
|
||||
EVENTREPLY, HEARTBEATREQUEST, IDENTPREFIX, IDENTREQUEST, \
|
||||
READREPLY, READREQUEST, REQUEST2REPLY, WRITEREPLY, WRITEREQUEST
|
||||
|
||||
# replies to be handled for cache
|
||||
UPDATE_MESSAGES = {EVENTREPLY, READREPLY, WRITEREPLY, ERRORPREFIX + READREQUEST, ERRORPREFIX + EVENTREPLY}
|
||||
|
||||
|
||||
class UNREGISTER:
|
||||
"""a magic value, used a returned value in a callback
|
||||
|
||||
to indicate it has to be unregistered
|
||||
used to implement one shot callbacks
|
||||
"""
|
||||
|
||||
|
||||
class Logger:
|
||||
"""dummy logger, in case not provided from caller"""
|
||||
|
||||
@staticmethod
|
||||
def info(fmt, *args, **kwds):
|
||||
print(str(fmt) % args)
|
||||
|
||||
@staticmethod
|
||||
def noop(fmt, *args, **kwds):
|
||||
pass
|
||||
|
||||
debug = noop
|
||||
error = warning = critical = info
|
||||
|
||||
|
||||
class CallbackObject:
|
||||
"""abstract definition for a target object for callbacks
|
||||
|
||||
this is mainly for documentation, but it might be extended
|
||||
and used as a mixin for objects registered as a callback
|
||||
"""
|
||||
def updateEvent(self, module, parameter, value, timestamp, readerror):
|
||||
"""called whenever a value is changed
|
||||
|
||||
or when new callbacks are registered
|
||||
"""
|
||||
|
||||
def unhandledMessage(self, action, ident, data):
|
||||
"""called on an unhandled message"""
|
||||
|
||||
def nodeStateChange(self, online, state):
|
||||
"""called when the state of the connection changes
|
||||
|
||||
'online' is True when connected or reconnecting, False when disconnected or connecting
|
||||
'state' is the connection state as a string
|
||||
"""
|
||||
|
||||
def descriptiveDataChange(self, module, description):
|
||||
"""called when the description has changed
|
||||
|
||||
this callback is called on the node with module=None
|
||||
and on every changed module with module==<module name>
|
||||
"""
|
||||
|
||||
|
||||
class ProxyClient:
|
||||
"""common functionality for proxy clients"""
|
||||
|
||||
CALLBACK_NAMES = ('updateEvent', 'descriptiveDataChange', 'nodeStateChange', 'unhandledMessage')
|
||||
online = False # connected or reconnecting since a short time
|
||||
validate_data = False
|
||||
state = 'disconnected' # further possible values: 'connecting', 'reconnecting', 'connected'
|
||||
|
||||
def __init__(self):
|
||||
self.callbacks = {cbname: defaultdict(list) for cbname in self.CALLBACK_NAMES}
|
||||
# caches (module, parameter) = value, timestamp, readerror (internal names!)
|
||||
self.cache = {}
|
||||
|
||||
def register_callback(self, key, *args, **kwds):
|
||||
"""register callback functions
|
||||
|
||||
- key might be either:
|
||||
1) None: general callback (all callbacks)
|
||||
2) <module name>: callbacks related to a module (not called for 'unhandledMessage')
|
||||
3) (<module name>, <parameter name>): callback for specified parameter (only called for 'updateEvent')
|
||||
- all the following arguments are callback functions. The callback name may be
|
||||
given by the keyword, or, for non-keyworded arguments it is taken from the
|
||||
__name__ attribute of the function
|
||||
"""
|
||||
for cbfunc in args:
|
||||
kwds[cbfunc.__name__] = cbfunc
|
||||
for cbname in self.CALLBACK_NAMES:
|
||||
cbfunc = kwds.pop(cbname, None)
|
||||
if not cbfunc:
|
||||
continue
|
||||
cbdict = self.callbacks[cbname]
|
||||
cbdict[key].append(cbfunc)
|
||||
|
||||
# immediately call for some callback types
|
||||
if cbname == 'updateEvent':
|
||||
if key is None:
|
||||
for (mname, pname), data in self.cache.items():
|
||||
cbfunc(mname, pname, *data)
|
||||
else:
|
||||
data = self.cache.get(key, None)
|
||||
if data:
|
||||
cbfunc(*key, *data) # case single parameter
|
||||
else: # case key = module
|
||||
for (mname, pname), data in self.cache.items():
|
||||
if mname == key:
|
||||
cbfunc(mname, pname, *data)
|
||||
elif cbname == 'nodeStateChange':
|
||||
cbfunc(self.online, self.state)
|
||||
if kwds:
|
||||
raise TypeError('unknown callback: %s' % (', '.join(kwds)))
|
||||
|
||||
def unregister_callback(self, key, *args, **kwds):
|
||||
"""unregister a callback
|
||||
|
||||
for the arguments see register_callback
|
||||
"""
|
||||
for cbfunc in args:
|
||||
kwds[cbfunc.__name__] = cbfunc
|
||||
for cbname, func in kwds.items():
|
||||
cblist = self.callbacks[cbname][key]
|
||||
if func in cblist:
|
||||
cblist.remove(func)
|
||||
if not cblist:
|
||||
self.callbacks[cbname].pop(key)
|
||||
|
||||
def callback(self, key, cbname, *args):
|
||||
"""perform callbacks
|
||||
|
||||
key=None:
|
||||
key=<module name>: callbacks for specified module
|
||||
key=(<module name>, <parameter name): callbacks for specified parameter
|
||||
"""
|
||||
cblist = self.callbacks[cbname].get(key, [])
|
||||
self.callbacks[cbname][key] = [cb for cb in cblist if cb(*args) is not UNREGISTER]
|
||||
return bool(cblist)
|
||||
|
||||
def updateValue(self, module, param, value, timestamp, readerror):
|
||||
if readerror:
|
||||
assert isinstance(readerror, Exception)
|
||||
if self.validate_data:
|
||||
try:
|
||||
# try to validate, reason: make enum_members from integers
|
||||
datatype = self.modules[module]['parameters'][param]['datatype']
|
||||
value = datatype(value)
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
self.cache[(module, param)] = (value, timestamp, readerror)
|
||||
self.callback(None, 'updateEvent', module, param, value, timestamp, readerror)
|
||||
self.callback(module, 'updateEvent', module, param, value, timestamp, readerror)
|
||||
self.callback((module, param), 'updateEvent', module, param, value, timestamp, readerror)
|
||||
|
||||
|
||||
class SecopClient(ProxyClient):
|
||||
"""a general SECoP client"""
|
||||
reconnect_timeout = 10
|
||||
_running = False
|
||||
_shutdown = False
|
||||
_rxthread = None
|
||||
_txthread = None
|
||||
_connthread = None
|
||||
disconnect_time = 0 # time of last disconnect
|
||||
secop_version = ''
|
||||
descriptive_data = {}
|
||||
modules = {}
|
||||
_last_error = None
|
||||
|
||||
def __init__(self, uri, log=Logger):
|
||||
super().__init__()
|
||||
# maps expected replies to [request, Event, is_error, result] until a response came
|
||||
# there can only be one entry per thread calling 'request'
|
||||
self.active_requests = {}
|
||||
self.io = None
|
||||
self.txq = queue.Queue(30) # queue for tx requests
|
||||
self.pending = queue.Queue(30) # requests with colliding action + ident
|
||||
self.log = log
|
||||
self.uri = uri
|
||||
self.nodename = uri
|
||||
self._lock = RLock()
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
self.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def connect(self, try_period=0):
|
||||
"""establish connection
|
||||
|
||||
if a <try_period> is given, repeat trying for the given time (sec)
|
||||
"""
|
||||
with self._lock:
|
||||
if self.io:
|
||||
return
|
||||
if self.online:
|
||||
self._set_state(True, 'reconnecting')
|
||||
else:
|
||||
self._set_state(False, 'connecting')
|
||||
deadline = time.time() + try_period
|
||||
while not self._shutdown:
|
||||
try:
|
||||
self.io = AsynConn(self.uri) # timeout 1 sec
|
||||
self.io.writeline(IDENTREQUEST.encode('utf-8'))
|
||||
reply = self.io.readline(10)
|
||||
if reply:
|
||||
self.secop_version = reply.decode('utf-8')
|
||||
else:
|
||||
raise self.error_map('HardwareError')('no answer to %s' % IDENTREQUEST)
|
||||
if not self.secop_version.startswith(IDENTPREFIX):
|
||||
raise self.error_map('HardwareError')('bad answer to %s: %r' %
|
||||
(IDENTREQUEST, self.secop_version))
|
||||
# now its safe to do secop stuff
|
||||
self._running = True
|
||||
self._rxthread = mkthread(self.__rxthread)
|
||||
self._txthread = mkthread(self.__txthread)
|
||||
self.log.debug('connected to %s', self.uri)
|
||||
# pylint: disable=unsubscriptable-object
|
||||
self._init_descriptive_data(self.request(DESCRIPTIONREQUEST)[2])
|
||||
self.nodename = self.properties.get('equipment_id', self.uri)
|
||||
if self.activate:
|
||||
self.request(ENABLEEVENTSREQUEST)
|
||||
self._set_state(True, 'connected')
|
||||
break
|
||||
except Exception:
|
||||
# print(formatExtendedTraceback())
|
||||
if time.time() > deadline:
|
||||
# stay online for now, if activated
|
||||
self._set_state(self.online and self.activate)
|
||||
raise
|
||||
time.sleep(1)
|
||||
if not self._shutdown:
|
||||
self.log.info('%s ready', self.nodename)
|
||||
|
||||
def __txthread(self):
|
||||
while self._running:
|
||||
entry = self.txq.get()
|
||||
if entry is None:
|
||||
break
|
||||
request = entry[0]
|
||||
reply_action = REQUEST2REPLY.get(request[0], None)
|
||||
if reply_action:
|
||||
key = (reply_action, request[1]) # action and identifier
|
||||
else: # allow experimental unknown requests, but only one at a time
|
||||
key = None
|
||||
if key in self.active_requests:
|
||||
# store to requeue after the next reply was received
|
||||
self.pending.put(entry)
|
||||
else:
|
||||
self.active_requests[key] = entry
|
||||
line = encode_msg_frame(*request)
|
||||
self.log.debug('TX: %r', line)
|
||||
self.io.send(line)
|
||||
self._txthread = None
|
||||
self.disconnect(False)
|
||||
|
||||
def __rxthread(self):
|
||||
noactivity = 0
|
||||
try:
|
||||
while self._running:
|
||||
# may raise ConnectionClosed
|
||||
reply = self.io.readline()
|
||||
if reply is None:
|
||||
noactivity += 1
|
||||
if noactivity % 5 == 0:
|
||||
# send ping to check if the connection is still alive
|
||||
self.queue_request(HEARTBEATREQUEST, str(noactivity))
|
||||
continue
|
||||
noactivity = 0
|
||||
action, ident, data = decode_msg(reply)
|
||||
if ident == '.':
|
||||
ident = None
|
||||
if action in UPDATE_MESSAGES:
|
||||
module_param = self.internal.get(ident, None)
|
||||
if module_param is None and ':' not in ident:
|
||||
# allow missing ':value'/':target'
|
||||
if action == WRITEREPLY:
|
||||
module_param = self.internal.get(ident + ':target', None)
|
||||
else:
|
||||
module_param = self.internal.get(ident + ':value', None)
|
||||
if module_param is not None:
|
||||
if action.startswith(ERRORPREFIX):
|
||||
timestamp = data[2].get('t', None)
|
||||
readerror = frappy.errors.make_secop_error(*data[0:2])
|
||||
value = None
|
||||
else:
|
||||
timestamp = data[1].get('t', None)
|
||||
value = data[0]
|
||||
readerror = None
|
||||
module, param = module_param
|
||||
try:
|
||||
self.updateValue(module, param, value, timestamp, readerror)
|
||||
except KeyError:
|
||||
pass # ignore updates of unknown parameters
|
||||
if action in (EVENTREPLY, ERRORPREFIX + EVENTREPLY):
|
||||
continue
|
||||
try:
|
||||
key = action, ident
|
||||
entry = self.active_requests.pop(key)
|
||||
except KeyError:
|
||||
if action.startswith(ERRORPREFIX):
|
||||
try:
|
||||
key = REQUEST2REPLY[action[len(ERRORPREFIX):]], ident
|
||||
except KeyError:
|
||||
key = None
|
||||
entry = self.active_requests.pop(key, None)
|
||||
else:
|
||||
# this may be a response to the last unknown request
|
||||
key = None
|
||||
entry = self.active_requests.pop(key, None)
|
||||
if entry is None:
|
||||
self._unhandled_message(action, ident, data)
|
||||
continue
|
||||
entry[2] = action, ident, data
|
||||
entry[1].set() # trigger event
|
||||
while not self.pending.empty():
|
||||
# let the TX thread sort out which entry to treat
|
||||
# this may have bad performance, but happens rarely
|
||||
self.txq.put(self.pending.get())
|
||||
except ConnectionClosed:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.log.error('rxthread ended with %r', e)
|
||||
self._rxthread = None
|
||||
self.disconnect(False)
|
||||
if self._shutdown:
|
||||
return
|
||||
if self.activate:
|
||||
self.log.info('try to reconnect to %s', self.uri)
|
||||
self._connthread = mkthread(self._reconnect)
|
||||
else:
|
||||
self.log.warning('%s disconnected', self.uri)
|
||||
self._set_state(False, 'disconnected')
|
||||
|
||||
def spawn_connect(self, connected_callback=None):
|
||||
"""try to connect in background
|
||||
|
||||
and trigger event when done and event is not None
|
||||
"""
|
||||
self.disconnect_time = time.time()
|
||||
self._connthread = mkthread(self._reconnect, connected_callback)
|
||||
|
||||
def _reconnect(self, connected_callback=None):
|
||||
while not self._shutdown:
|
||||
try:
|
||||
self.connect()
|
||||
if connected_callback:
|
||||
connected_callback()
|
||||
break
|
||||
except Exception as e:
|
||||
txt = str(e).split('\n', 1)[0]
|
||||
if txt != self._last_error:
|
||||
self._last_error = txt
|
||||
if 'join' in str(e):
|
||||
raise
|
||||
self.log.error(str(e))
|
||||
if time.time() > self.disconnect_time + self.reconnect_timeout:
|
||||
if self.online: # was recently connected
|
||||
self.disconnect_time = 0
|
||||
self.log.warning('can not reconnect to %s (%r)' % (self.nodename, e))
|
||||
self.log.info('continue trying to reconnect')
|
||||
# self.log.warning(formatExtendedTraceback())
|
||||
self._set_state(False)
|
||||
time.sleep(self.reconnect_timeout)
|
||||
else:
|
||||
time.sleep(1)
|
||||
self._connthread = None
|
||||
|
||||
def disconnect(self, shutdown=True):
|
||||
self._running = False
|
||||
if shutdown:
|
||||
self._shutdown = True
|
||||
self._set_state(False, 'shutdown')
|
||||
if self._connthread:
|
||||
if self._connthread == current_thread():
|
||||
return
|
||||
# wait for connection thread stopped
|
||||
self._connthread.join()
|
||||
self._connthread = None
|
||||
self.disconnect_time = time.time()
|
||||
try: # make sure txq does not block
|
||||
while not self.txq.empty():
|
||||
self.txq.get(False)
|
||||
except Exception:
|
||||
pass
|
||||
if self._txthread:
|
||||
self.txq.put(None) # shutdown marker
|
||||
self._txthread.join()
|
||||
self._txthread = None
|
||||
if self._rxthread:
|
||||
self._rxthread.join()
|
||||
self._rxthread = None
|
||||
if self.io:
|
||||
self.io.disconnect()
|
||||
self.io = None
|
||||
# abort pending requests early
|
||||
try: # avoid race condition
|
||||
while self.active_requests:
|
||||
_, (_, event, _) = self.active_requests.popitem()
|
||||
event.set()
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
while True:
|
||||
_, event, _ = self.pending.get(block=False)
|
||||
event.set()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def _init_descriptive_data(self, data):
|
||||
"""rebuild descriptive data"""
|
||||
changed_modules = None
|
||||
if json.dumps(data, sort_keys=True) != json.dumps(self.descriptive_data, sort_keys=True):
|
||||
if self.descriptive_data:
|
||||
changed_modules = set()
|
||||
modules = data.get('modules', {})
|
||||
for modname, moddesc in self.descriptive_data['modules'].items():
|
||||
if json.dumps(moddesc, sort_keys=True) != json.dumps(modules.get(modname), sort_keys=True):
|
||||
changed_modules.add(modname)
|
||||
self.descriptive_data = data
|
||||
modules = data['modules']
|
||||
self.modules = {}
|
||||
self.properties = {k: v for k, v in data.items() if k != 'modules'}
|
||||
self.identifier = {} # map (module, parameter) -> identifier
|
||||
self.internal = {} # map identifier -> (module, parameter)
|
||||
for modname, moddescr in modules.items():
|
||||
# separate accessibles into command and parameters
|
||||
parameters = {}
|
||||
commands = {}
|
||||
accessibles = moddescr['accessibles']
|
||||
for aname, aentry in accessibles.items():
|
||||
iname = self.internalize_name(aname)
|
||||
datatype = get_datatype(aentry['datainfo'], iname)
|
||||
aentry = dict(aentry, datatype=datatype)
|
||||
ident = '%s:%s' % (modname, aname)
|
||||
self.identifier[modname, iname] = ident
|
||||
self.internal[ident] = modname, iname
|
||||
if datatype.IS_COMMAND:
|
||||
commands[iname] = aentry
|
||||
else:
|
||||
parameters[iname] = aentry
|
||||
properties = {k: v for k, v in moddescr.items() if k != 'accessibles'}
|
||||
self.modules[modname] = dict(accessibles=accessibles, parameters=parameters,
|
||||
commands=commands, properties=properties)
|
||||
if changed_modules is not None:
|
||||
done = done_main = self.callback(None, 'descriptiveDataChange', None, self)
|
||||
for mname in changed_modules:
|
||||
if not self.callback(mname, 'descriptiveDataChange', mname, self):
|
||||
if not done_main:
|
||||
self.log.warning('descriptive data changed on module %r', mname)
|
||||
done = True
|
||||
if not done:
|
||||
self.log.warning('descriptive data of %r changed', self.nodename)
|
||||
|
||||
def _unhandled_message(self, action, ident, data):
|
||||
if not self.callback(None, 'unhandledMessage', action, ident, data):
|
||||
self.log.warning('unhandled message: %s %s %r', action, ident, data)
|
||||
|
||||
def _set_state(self, online, state=None):
|
||||
# remark: reconnecting is treated as online
|
||||
self.online = online
|
||||
self.state = state or self.state
|
||||
self.callback(None, 'nodeStateChange', self.online, self.state)
|
||||
for mname in self.modules:
|
||||
self.callback(mname, 'nodeStateChange', self.online, self.state)
|
||||
|
||||
def queue_request(self, action, ident=None, data=None):
|
||||
"""make a request"""
|
||||
request = action, ident, data
|
||||
self.connect() # make sure we are connected
|
||||
# the last item is for the reply
|
||||
entry = [request, Event(), None]
|
||||
self.txq.put(entry, timeout=3)
|
||||
return entry
|
||||
|
||||
def get_reply(self, entry):
|
||||
"""wait for reply and return it"""
|
||||
if not entry[1].wait(10): # event
|
||||
raise TimeoutError('no response within 10s')
|
||||
if not entry[2]: # reply
|
||||
raise ConnectionError('connection closed before reply')
|
||||
action, _, data = entry[2] # pylint: disable=unpacking-non-sequence
|
||||
if action.startswith(ERRORPREFIX):
|
||||
errcls = self.error_map(data[0])
|
||||
raise errcls(data[1])
|
||||
return entry[2] # reply
|
||||
|
||||
def request(self, action, ident=None, data=None):
|
||||
"""make a request
|
||||
|
||||
and wait for reply
|
||||
"""
|
||||
entry = self.queue_request(action, ident, data)
|
||||
return self.get_reply(entry)
|
||||
|
||||
def readParameter(self, module, parameter):
|
||||
"""forced read over connection"""
|
||||
try:
|
||||
self.request(READREQUEST, self.identifier[module, parameter])
|
||||
except frappy.errors.SECoPError:
|
||||
# error reply message is already stored as readerror in cache
|
||||
pass
|
||||
return self.cache.get((module, parameter), None)
|
||||
|
||||
def getParameter(self, module, parameter, trycache=False):
|
||||
if trycache:
|
||||
cached = self.cache.get((module, parameter), None)
|
||||
if cached:
|
||||
return cached
|
||||
if self.online:
|
||||
self.readParameter(module, parameter)
|
||||
return self.cache[module, parameter]
|
||||
|
||||
def setParameter(self, module, parameter, value):
|
||||
self.connect() # make sure we are connected
|
||||
datatype = self.modules[module]['parameters'][parameter]['datatype']
|
||||
value = datatype.export_value(value)
|
||||
self.request(WRITEREQUEST, self.identifier[module, parameter], value)
|
||||
return self.cache[module, parameter]
|
||||
|
||||
def execCommand(self, module, command, argument=None):
|
||||
self.connect() # make sure we are connected
|
||||
datatype = self.modules[module]['commands'][command]['datatype'].argument
|
||||
if datatype:
|
||||
argument = datatype.export_value(argument)
|
||||
else:
|
||||
if argument is not None:
|
||||
raise frappy.errors.BadValueError('command has no argument')
|
||||
# pylint: disable=unsubscriptable-object
|
||||
data, qualifiers = self.request(COMMANDREQUEST, self.identifier[module, command], argument)[2]
|
||||
datatype = self.modules[module]['commands'][command]['datatype'].result
|
||||
if datatype:
|
||||
data = datatype.import_value(data)
|
||||
return data, qualifiers
|
||||
|
||||
# the following attributes may be/are intended to be overwritten by a subclass
|
||||
|
||||
ERROR_MAP = frappy.errors.EXCEPTIONS
|
||||
DEFAULT_EXCEPTION = frappy.errors.SECoPError
|
||||
PREDEFINED_NAMES = set(frappy.params.PREDEFINED_ACCESSIBLES)
|
||||
activate = True
|
||||
|
||||
def error_map(self, exc):
|
||||
"""how to convert SECoP and unknown exceptions"""
|
||||
return self.ERROR_MAP.get(exc, self.DEFAULT_EXCEPTION)
|
||||
|
||||
def internalize_name(self, name):
|
||||
"""how to create internal names"""
|
||||
if name.startswith('_') and name[1:] not in self.PREDEFINED_NAMES:
|
||||
return name[1:]
|
||||
return name
|
||||
289
frappy/client/interactive.py
Normal file
289
frappy/client/interactive.py
Normal file
@@ -0,0 +1,289 @@
|
||||
# -*- 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>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""simple interactive python client"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import re
|
||||
from queue import Queue
|
||||
from frappy.client import SecopClient
|
||||
from frappy.errors import SECoPError
|
||||
from frappy.datatypes import get_datatype
|
||||
|
||||
USAGE = """
|
||||
Usage:
|
||||
|
||||
from frappy.client.interactive import Client
|
||||
|
||||
client = Client('localhost:5000') # start client.
|
||||
# this connects and creates objects for all SECoP modules in the main namespace
|
||||
|
||||
<module> # list all parameters
|
||||
<module>.<param> = <value> # change parameter
|
||||
<module>(<target>) # set target and wait until not busy
|
||||
# 'status' and 'value' changes are shown every 1 sec
|
||||
client.mininterval = 0.2 # change minimal update interval to 0.2 sec (default is 1 second)
|
||||
|
||||
<module>.watch(1) # watch changes of all parameters of a module
|
||||
<module>.watch(0) # remove all watching
|
||||
<module>.watch(status=1, value=1) # add 'status' and 'value' to watched parameters
|
||||
<module>.watch(value=0) # remove 'value' from watched parameters
|
||||
"""
|
||||
|
||||
main = sys.modules['__main__']
|
||||
|
||||
|
||||
class Logger:
|
||||
def __init__(self, loglevel='info'):
|
||||
func = self.noop
|
||||
for lev in 'debug', 'info', 'warning', 'error':
|
||||
if lev == loglevel:
|
||||
func = self.emit
|
||||
setattr(self, lev, func)
|
||||
self._minute = 0
|
||||
|
||||
def emit(self, fmt, *args, **kwds):
|
||||
now = time.time()
|
||||
minute = now // 60
|
||||
if minute != self._minute:
|
||||
self._minute = minute
|
||||
print(time.strftime('--- %H:%M:%S ---', time.localtime(now)))
|
||||
print('%6.3f' % (now % 60.0), str(fmt) % args)
|
||||
|
||||
@staticmethod
|
||||
def noop(fmt, *args, **kwds):
|
||||
pass
|
||||
|
||||
|
||||
class PrettyFloat(float):
|
||||
def __repr__(self):
|
||||
result = '%.12g' % self
|
||||
if '.' in result or 'e' in result:
|
||||
return result
|
||||
return result + '.'
|
||||
|
||||
|
||||
class Module:
|
||||
_log_pattern = re.compile('.*')
|
||||
|
||||
def __init__(self, name, secnode):
|
||||
self._name = name
|
||||
self._secnode = secnode
|
||||
self._parameters = list(secnode.modules[name]['parameters'])
|
||||
self._commands = list(secnode.modules[name]['commands'])
|
||||
self._running = None
|
||||
self._status = None
|
||||
props = secnode.modules[name]['properties']
|
||||
self._title = '# %s (%s)' % (props.get('implementation', ''), props.get('interface_classes', [''])[0])
|
||||
|
||||
def _one_line(self, pname, minwid=0):
|
||||
"""return <module>.<param> = <value> truncated to one line"""
|
||||
param = getattr(type(self), pname)
|
||||
try:
|
||||
value = getattr(self, pname)
|
||||
r = param.format(value)
|
||||
except Exception as e:
|
||||
r = repr(e)
|
||||
pname = pname.ljust(minwid)
|
||||
vallen = 113 - len(self._name) - len(pname)
|
||||
if len(r) > vallen:
|
||||
r = r[:vallen - 4] + ' ...'
|
||||
return '%s.%s = %s' % (self._name, pname, r)
|
||||
|
||||
def _isBusy(self):
|
||||
return 300 <= self.status[0] < 400
|
||||
|
||||
def _status_value_update(self, m, p, status, t, e):
|
||||
if self._running:
|
||||
try:
|
||||
self._running.put(True)
|
||||
if self._running and not self._isBusy():
|
||||
self._running.put(False)
|
||||
except TypeError: # may happen when _running is removed during above lines
|
||||
pass
|
||||
|
||||
def _watch_parameter(self, m, pname, *args, forced=False, mininterval=0):
|
||||
"""show parameter update"""
|
||||
pobj = getattr(type(self), pname)
|
||||
if not args:
|
||||
args = self._secnode.cache[self._name, pname]
|
||||
value = args[0]
|
||||
now = time.time()
|
||||
if (value != pobj.prev and now >= pobj.prev_time + mininterval) or forced:
|
||||
self._secnode.log.info('%s', self._one_line(pname))
|
||||
pobj.prev = value
|
||||
pobj.prev_time = now
|
||||
|
||||
def watch(self, *args, **kwds):
|
||||
enabled = {}
|
||||
for arg in args:
|
||||
if arg == 1: # or True
|
||||
enabled.update({k: True for k in self._parameters})
|
||||
elif arg == 0: # or False
|
||||
enabled.update({k: False for k in self._parameters})
|
||||
else:
|
||||
enabled.update(arg)
|
||||
enabled.update(kwds)
|
||||
for pname, enable in enabled.items():
|
||||
self._secnode.unregister_callback((self._name, pname), updateEvent=self._watch_parameter)
|
||||
if enable:
|
||||
self._secnode.register_callback((self._name, pname), updateEvent=self._watch_parameter)
|
||||
|
||||
def read(self, pname='value'):
|
||||
value, _, error = self._secnode.readParameter(self._name, pname)
|
||||
if error:
|
||||
raise error
|
||||
return value
|
||||
|
||||
def __call__(self, target=None):
|
||||
if target is None:
|
||||
return self.read()
|
||||
self.target = target # this sets self._running
|
||||
type(self).value.prev = None # show at least one value
|
||||
show_final_value = True
|
||||
try:
|
||||
while self._running.get():
|
||||
self._watch_parameter(self._name, 'value', mininterval=self._secnode.mininterval)
|
||||
self._watch_parameter(self._name, 'status')
|
||||
except KeyboardInterrupt:
|
||||
self._secnode.log.info('-- interrupted --')
|
||||
self._running = None
|
||||
self._watch_parameter(self._name, 'status')
|
||||
self._secnode.readParameter(self._name, 'value')
|
||||
self._watch_parameter(self._name, 'value', forced=show_final_value)
|
||||
return self.value
|
||||
|
||||
def __repr__(self):
|
||||
wid = max(len(k) for k in self._parameters)
|
||||
return '%s\n%s\nCommands: %s' % (
|
||||
self._title,
|
||||
'\n'.join(self._one_line(k, wid) for k in self._parameters),
|
||||
', '.join(k + '()' for k in self._commands))
|
||||
|
||||
def logging(self, level='comlog', pattern='.*'):
|
||||
self._log_pattern = re.compile(pattern)
|
||||
self._secnode.request('logging', self._name, level)
|
||||
|
||||
def handle_log_message_(self, data):
|
||||
if self._log_pattern.match(data):
|
||||
self._secnode.log.info('%s: %r', self._name, data)
|
||||
|
||||
|
||||
class Param:
|
||||
def __init__(self, name, datainfo):
|
||||
self.name = name
|
||||
self.prev = None
|
||||
self.prev_time = 0
|
||||
self.datatype = get_datatype(datainfo)
|
||||
|
||||
def __get__(self, obj, owner):
|
||||
if obj is None:
|
||||
return self
|
||||
value, _, error = obj._secnode.cache[obj._name, self.name]
|
||||
if error:
|
||||
raise error
|
||||
return value
|
||||
|
||||
def __set__(self, obj, value):
|
||||
if self.name == 'target':
|
||||
obj._running = Queue()
|
||||
try:
|
||||
obj._secnode.setParameter(obj._name, self.name, value)
|
||||
except SECoPError as e:
|
||||
obj._secnode.log.error(repr(e))
|
||||
|
||||
def format(self, value):
|
||||
return self.datatype.format_value(value)
|
||||
|
||||
|
||||
class Command:
|
||||
def __init__(self, name, modname, secnode):
|
||||
self.name = name
|
||||
self.modname = modname
|
||||
self.exec = secnode.execCommand
|
||||
|
||||
def call(self, *args, **kwds):
|
||||
if kwds:
|
||||
if args:
|
||||
raise TypeError('mixed arguments forbidden')
|
||||
result, _ = self.exec(self.modname, self.name, kwds)
|
||||
else:
|
||||
result, _ = self.exec(self.modname, self.name, args or None)
|
||||
return result
|
||||
|
||||
def __get__(self, obj, owner=None):
|
||||
if obj is None:
|
||||
return self
|
||||
return self.call
|
||||
|
||||
|
||||
class Client(SecopClient):
|
||||
activate = True
|
||||
secnodes = {}
|
||||
mininterval = 1
|
||||
|
||||
def __init__(self, uri, loglevel='info'):
|
||||
# remove previous client:
|
||||
prev = self.secnodes.pop(uri, None)
|
||||
if prev:
|
||||
prev.log.info('remove previous client to %s', uri)
|
||||
for modname in prev.modules:
|
||||
prevnode = getattr(getattr(main, modname, None), 'secnode', None)
|
||||
if prevnode == prev:
|
||||
prev.log.info('remove previous module %s', modname)
|
||||
delattr(main, modname)
|
||||
prev.disconnect()
|
||||
self.secnodes[uri] = self
|
||||
super().__init__(uri, Logger(loglevel))
|
||||
self.connect()
|
||||
for modname, moddesc in self.modules.items():
|
||||
prev = getattr(main, modname, None)
|
||||
if prev is None:
|
||||
self.log.info('create module %s', modname)
|
||||
else:
|
||||
if getattr(prev, 'secnode', None) is None:
|
||||
self.log.error('skip module %s overwriting a global variable' % modname)
|
||||
continue
|
||||
self.log.info('overwrite module %s', modname)
|
||||
attrs = {}
|
||||
for pname, pinfo in moddesc['parameters'].items():
|
||||
attrs[pname] = Param(pname, pinfo['datainfo'])
|
||||
for cname in moddesc['commands']:
|
||||
attrs[cname] = Command(cname, modname, self)
|
||||
mobj = type('M_%s' % modname, (Module,), attrs)(modname, self)
|
||||
if 'status' in mobj._parameters:
|
||||
self.register_callback((modname, 'status'), updateEvent=mobj._status_value_update)
|
||||
self.register_callback((modname, 'value'), updateEvent=mobj._status_value_update)
|
||||
setattr(main, modname, mobj)
|
||||
self.register_callback(None, self.unhandledMessage)
|
||||
self.log.info('%s', USAGE)
|
||||
|
||||
def unhandledMessage(self, action, ident, data):
|
||||
"""handle logging messages"""
|
||||
if action == 'log':
|
||||
modname = ident.split(':')[0]
|
||||
modobj = getattr(main, modname, None)
|
||||
if modobj:
|
||||
modobj.handle_log_message_(data)
|
||||
return
|
||||
self.log.info('module %s not found', modname)
|
||||
self.log.info('unhandled: %s %s %r', action, ident, data)
|
||||
Reference in New Issue
Block a user