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
22
frappy/protocol/__init__.py
Normal file
22
frappy/protocol/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- 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>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""SECoP protocol specific stuff"""
|
||||
403
frappy/protocol/dispatcher.py
Normal file
403
frappy/protocol/dispatcher.py
Normal file
@@ -0,0 +1,403 @@
|
||||
# -*- 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>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Dispatcher for SECoP Messages
|
||||
|
||||
Interface to the service offering part:
|
||||
|
||||
- 'handle_request(connectionobj, data)' handles incoming request
|
||||
it returns the (sync) reply, and it may call 'send_reply(data)'
|
||||
on the connectionobj or on activated connections
|
||||
- 'add_connection(connectionobj)' registers new connection
|
||||
- 'remove_connection(connectionobj)' removes now longer functional connection
|
||||
|
||||
Interface to the modules:
|
||||
- add_module(modulename, moduleobj, export=True) registers a new module under the
|
||||
given name, may also register it for exporting (making accessible)
|
||||
- get_module(modulename) returns the requested module or None
|
||||
- remove_module(modulename_or_obj): removes the module (during shutdown)
|
||||
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from time import time as currenttime
|
||||
|
||||
from frappy.errors import NoSuchCommandError, NoSuchModuleError, \
|
||||
NoSuchParameterError, ProtocolError, ReadOnlyError, SECoPServerError
|
||||
from frappy.params import Parameter
|
||||
from frappy.protocol.messages import COMMANDREPLY, DESCRIPTIONREPLY, \
|
||||
DISABLEEVENTSREPLY, ENABLEEVENTSREPLY, ERRORPREFIX, EVENTREPLY, \
|
||||
HEARTBEATREPLY, IDENTREPLY, IDENTREQUEST, READREPLY, WRITEREPLY, \
|
||||
LOGGING_REPLY, LOG_EVENT
|
||||
|
||||
|
||||
def make_update(modulename, pobj):
|
||||
if pobj.readerror:
|
||||
return (ERRORPREFIX + EVENTREPLY, '%s:%s' % (modulename, pobj.export),
|
||||
# error-report !
|
||||
[pobj.readerror.name, repr(pobj.readerror), dict(t=pobj.timestamp)])
|
||||
return (EVENTREPLY, '%s:%s' % (modulename, pobj.export),
|
||||
[pobj.export_value(), dict(t=pobj.timestamp)])
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
def __init__(self, name, logger, options, srv):
|
||||
# to avoid errors, we want to eat all options here
|
||||
self.equipment_id = options.pop('id', name)
|
||||
# time interval for omitting updates of unchanged values
|
||||
self.omit_unchanged_within = options.pop('omit_unchanged_within', 0.1)
|
||||
self.nodeprops = {}
|
||||
for k in list(options):
|
||||
self.nodeprops[k] = options.pop(k)
|
||||
|
||||
self.log = logger
|
||||
# map ALL modulename -> moduleobj
|
||||
self._modules = {}
|
||||
# list of EXPORTED modules
|
||||
self._export = []
|
||||
# list all connections
|
||||
self._connections = []
|
||||
# active (i.e. broadcast-receiving) connections
|
||||
self._active_connections = set()
|
||||
# map eventname -> list of subscribed connections
|
||||
# eventname is <modulename> or <modulename>:<parametername>
|
||||
self._subscriptions = {}
|
||||
self._lock = threading.RLock()
|
||||
self.name = name
|
||||
self.restart = srv.restart
|
||||
self.shutdown = srv.shutdown
|
||||
|
||||
def broadcast_event(self, msg, reallyall=False):
|
||||
"""broadcasts a msg to all active connections
|
||||
|
||||
used from the dispatcher"""
|
||||
if reallyall:
|
||||
listeners = self._connections
|
||||
else:
|
||||
# all subscribers to module:param
|
||||
listeners = self._subscriptions.get(msg[1], set()).copy()
|
||||
# all subscribers to module
|
||||
module = msg[1].split(':', 1)[0]
|
||||
listeners.update(self._subscriptions.get(module, set()))
|
||||
# all generic subscribers
|
||||
listeners.update(self._active_connections)
|
||||
for conn in listeners:
|
||||
conn.send_reply(msg)
|
||||
|
||||
def announce_update(self, modulename, pname, pobj):
|
||||
"""called by modules param setters to notify subscribers of new values
|
||||
"""
|
||||
self.broadcast_event(make_update(modulename, pobj))
|
||||
|
||||
def subscribe(self, conn, eventname):
|
||||
self._subscriptions.setdefault(eventname, set()).add(conn)
|
||||
|
||||
def unsubscribe(self, conn, eventname):
|
||||
if ':' not in eventname:
|
||||
# also remove 'more specific' subscriptions
|
||||
for k, v in self._subscriptions.items():
|
||||
if k.startswith('%s:' % eventname):
|
||||
v.discard(conn)
|
||||
if eventname in self._subscriptions:
|
||||
self._subscriptions[eventname].discard(conn)
|
||||
|
||||
def add_connection(self, conn):
|
||||
"""registers new connection"""
|
||||
self._connections.append(conn)
|
||||
|
||||
def reset_connection(self, conn):
|
||||
"""remove all subscriptions for a connection
|
||||
|
||||
to be called on the identification message
|
||||
"""
|
||||
for _evt, conns in list(self._subscriptions.items()):
|
||||
conns.discard(conn)
|
||||
self.set_all_log_levels(conn, 'off')
|
||||
self._active_connections.discard(conn)
|
||||
|
||||
def remove_connection(self, conn):
|
||||
"""removes now longer functional connection"""
|
||||
if conn in self._connections:
|
||||
self._connections.remove(conn)
|
||||
self.reset_connection(conn)
|
||||
|
||||
def register_module(self, moduleobj, modulename, export=True):
|
||||
self.log.debug('registering module %r as %s (export=%r)' %
|
||||
(moduleobj, modulename, export))
|
||||
self._modules[modulename] = moduleobj
|
||||
if export:
|
||||
self._export.append(modulename)
|
||||
|
||||
def get_module(self, modulename):
|
||||
if modulename in self._modules:
|
||||
return self._modules[modulename]
|
||||
if modulename in list(self._modules.values()):
|
||||
return modulename
|
||||
raise NoSuchModuleError('Module %r does not exist on this SEC-Node!' % modulename)
|
||||
|
||||
def remove_module(self, modulename_or_obj):
|
||||
moduleobj = self.get_module(modulename_or_obj)
|
||||
modulename = moduleobj.name
|
||||
if modulename in self._export:
|
||||
self._export.remove(modulename)
|
||||
self._modules.pop(modulename)
|
||||
self._subscriptions.pop(modulename, None)
|
||||
for k in [kk for kk in self._subscriptions if kk.startswith('%s:' % modulename)]:
|
||||
self._subscriptions.pop(k, None)
|
||||
|
||||
def list_module_names(self):
|
||||
# return a copy of our list
|
||||
return self._export[:]
|
||||
|
||||
def export_accessibles(self, modulename):
|
||||
self.log.debug('export_accessibles(%r)' % modulename)
|
||||
if modulename in self._export:
|
||||
# omit export=False params!
|
||||
res = OrderedDict()
|
||||
for aobj in self.get_module(modulename).accessibles.values():
|
||||
if aobj.export:
|
||||
res[aobj.export] = aobj.for_export()
|
||||
self.log.debug('list accessibles for module %s -> %r' %
|
||||
(modulename, res))
|
||||
return res
|
||||
self.log.debug('-> module is not to be exported!')
|
||||
return OrderedDict()
|
||||
|
||||
def get_descriptive_data(self, specifier):
|
||||
"""returns a python object which upon serialisation results in the descriptive data"""
|
||||
modules = {}
|
||||
result = {'modules': modules}
|
||||
for modulename in self._export:
|
||||
module = self.get_module(modulename)
|
||||
if not module.export:
|
||||
continue
|
||||
# some of these need rework !
|
||||
mod_desc = {'accessibles': self.export_accessibles(modulename)}
|
||||
mod_desc.update(module.exportProperties())
|
||||
mod_desc.pop('export', False)
|
||||
modules[modulename] = mod_desc
|
||||
modname, _, pname = (specifier or '').partition(':')
|
||||
if modname in modules: # extension to SECoP standard: description of a single module
|
||||
result = modules[modname]
|
||||
if pname in result['accessibles']: # extension to SECoP standard: description of a single accessible
|
||||
# command is also accepted
|
||||
result = result['accessibles'][pname]
|
||||
elif pname:
|
||||
raise NoSuchParameterError('Module %r has no parameter %r' % (modname, pname))
|
||||
elif not modname or modname == '.':
|
||||
result['equipment_id'] = self.equipment_id
|
||||
result['firmware'] = 'FRAPPY - The Python Framework for SECoP'
|
||||
result['version'] = '2021.02'
|
||||
result.update(self.nodeprops)
|
||||
else:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modname)
|
||||
return result
|
||||
|
||||
def _execute_command(self, modulename, exportedname, argument=None):
|
||||
moduleobj = self.get_module(modulename)
|
||||
if moduleobj is None:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modulename)
|
||||
|
||||
cname = moduleobj.accessiblename2attr.get(exportedname)
|
||||
cobj = moduleobj.commands.get(cname)
|
||||
if cobj is None:
|
||||
raise NoSuchCommandError('Module %r has no command %r' % (modulename, cname or exportedname))
|
||||
|
||||
if cobj.argument:
|
||||
argument = cobj.argument.import_value(argument)
|
||||
# now call func
|
||||
# note: exceptions are handled in handle_request, not here!
|
||||
result = cobj.do(moduleobj, argument)
|
||||
if cobj.result:
|
||||
result = cobj.result.export_value(result)
|
||||
return result, dict(t=currenttime())
|
||||
|
||||
def _setParameterValue(self, modulename, exportedname, value):
|
||||
moduleobj = self.get_module(modulename)
|
||||
if moduleobj is None:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modulename)
|
||||
|
||||
pname = moduleobj.accessiblename2attr.get(exportedname)
|
||||
pobj = moduleobj.parameters.get(pname)
|
||||
if pobj is None:
|
||||
raise NoSuchParameterError('Module %r has no parameter %r' % (modulename, pname or exportedname))
|
||||
if pobj.constant is not None:
|
||||
raise ReadOnlyError("Parameter %s:%s is constant and can not be changed remotely"
|
||||
% (modulename, pname))
|
||||
if pobj.readonly:
|
||||
raise ReadOnlyError("Parameter %s:%s can not be changed remotely"
|
||||
% (modulename, pname))
|
||||
|
||||
# validate!
|
||||
value = pobj.datatype(value)
|
||||
# note: exceptions are handled in handle_request, not here!
|
||||
getattr(moduleobj, 'write_' + pname)(value)
|
||||
# return value is ignored here, as already handled
|
||||
return pobj.export_value(), dict(t=pobj.timestamp) if pobj.timestamp else {}
|
||||
|
||||
def _getParameterValue(self, modulename, exportedname):
|
||||
moduleobj = self.get_module(modulename)
|
||||
if moduleobj is None:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modulename)
|
||||
|
||||
pname = moduleobj.accessiblename2attr.get(exportedname)
|
||||
pobj = moduleobj.parameters.get(pname)
|
||||
if pobj is None:
|
||||
raise NoSuchParameterError('Module %r has no parameter %r' % (modulename, pname or exportedname))
|
||||
if pobj.constant is not None:
|
||||
# really needed? we could just construct a readreply instead....
|
||||
# raise ReadOnlyError('This parameter is constant and can not be accessed remotely.')
|
||||
return pobj.datatype.export_value(pobj.constant)
|
||||
|
||||
# note: exceptions are handled in handle_request, not here!
|
||||
getattr(moduleobj, 'read_' + pname)()
|
||||
# return value is ignored here, as already handled
|
||||
return pobj.export_value(), dict(t=pobj.timestamp) if pobj.timestamp else {}
|
||||
|
||||
#
|
||||
# api to be called from the 'interface'
|
||||
# any method above has no idea about 'messages', this is handled here
|
||||
#
|
||||
def handle_request(self, conn, msg):
|
||||
"""handles incoming request
|
||||
|
||||
will return return reply, may send replies to conn or
|
||||
activated connections in addition
|
||||
"""
|
||||
self.log.debug('Dispatcher: handling msg: %s' % repr(msg))
|
||||
|
||||
# play thread safe !
|
||||
# XXX: ONLY ONE REQUEST (per dispatcher) AT A TIME
|
||||
with self._lock:
|
||||
action, specifier, data = msg
|
||||
# special case for *IDN?
|
||||
if action == IDENTREQUEST:
|
||||
action, specifier, data = '_ident', None, None
|
||||
|
||||
self.log.debug('Looking for handle_%s' % action)
|
||||
handler = getattr(self, 'handle_%s' % action, None)
|
||||
|
||||
if handler:
|
||||
return handler(conn, specifier, data)
|
||||
raise SECoPServerError('unhandled message: %s' % repr(msg))
|
||||
|
||||
# now the (defined) handlers for the different requests
|
||||
def handle_help(self, conn, specifier, data):
|
||||
self.log.error('should have been handled in the interface!')
|
||||
|
||||
def handle__ident(self, conn, specifier, data):
|
||||
# Remark: the following line is needed due to issue 66.
|
||||
self.reset_connection(conn)
|
||||
# The other stuff in issue 66 ('error_closed' message), has to be implemented
|
||||
# if and when frappy will support serial server connections
|
||||
return (IDENTREPLY, None, None)
|
||||
|
||||
def handle_describe(self, conn, specifier, data):
|
||||
return (DESCRIPTIONREPLY, specifier or '.', self.get_descriptive_data(specifier))
|
||||
|
||||
def handle_read(self, conn, specifier, data):
|
||||
if data:
|
||||
raise ProtocolError('read requests don\'t take data!')
|
||||
modulename, pname = specifier, 'value'
|
||||
if ':' in specifier:
|
||||
modulename, pname = specifier.split(':', 1)
|
||||
# XXX: trigger polling and force sending event ???
|
||||
return (READREPLY, specifier, list(self._getParameterValue(modulename, pname)))
|
||||
|
||||
def handle_change(self, conn, specifier, data):
|
||||
modulename, pname = specifier, 'target'
|
||||
if ':' in specifier:
|
||||
modulename, pname = specifier.split(':', 1)
|
||||
return (WRITEREPLY, specifier, list(self._setParameterValue(modulename, pname, data)))
|
||||
|
||||
def handle_do(self, conn, specifier, data):
|
||||
modulename, cmd = specifier.split(':', 1)
|
||||
return (COMMANDREPLY, specifier, list(self._execute_command(modulename, cmd, data)))
|
||||
|
||||
def handle_ping(self, conn, specifier, data):
|
||||
if data:
|
||||
raise ProtocolError('ping requests don\'t take data!')
|
||||
return (HEARTBEATREPLY, specifier, [None, {'t': currenttime()}])
|
||||
|
||||
def handle_activate(self, conn, specifier, data):
|
||||
if data:
|
||||
raise ProtocolError('activate requests don\'t take data!')
|
||||
if specifier:
|
||||
modulename, exportedname = specifier, None
|
||||
if ':' in specifier:
|
||||
modulename, exportedname = specifier.split(':', 1)
|
||||
if modulename not in self._export:
|
||||
raise NoSuchModuleError('Module %r does not exist' % modulename)
|
||||
moduleobj = self.get_module(modulename)
|
||||
if exportedname is not None:
|
||||
pname = moduleobj.accessiblename2attr.get(exportedname, True)
|
||||
if pname and pname not in moduleobj.accessibles:
|
||||
# what if we try to subscribe a command here ???
|
||||
raise NoSuchParameterError('Module %r has no parameter %r' % (modulename, pname))
|
||||
modules = [(modulename, pname)]
|
||||
else:
|
||||
modules = [(modulename, None)]
|
||||
# activate only ONE item (module or module:parameter)
|
||||
self.subscribe(conn, specifier)
|
||||
else:
|
||||
# activate all modules
|
||||
self._active_connections.add(conn)
|
||||
modules = [(m, None) for m in self._export]
|
||||
|
||||
# send updates for all subscribed values.
|
||||
# note: The initial poll already happend before the server is active
|
||||
for modulename, pname in modules:
|
||||
moduleobj = self._modules.get(modulename, None)
|
||||
if pname:
|
||||
conn.send_reply(make_update(modulename, moduleobj.parameters[pname]))
|
||||
continue
|
||||
for pobj in moduleobj.accessibles.values():
|
||||
if isinstance(pobj, Parameter) and pobj.export:
|
||||
conn.send_reply(make_update(modulename, pobj))
|
||||
return (ENABLEEVENTSREPLY, specifier, None) if specifier else (ENABLEEVENTSREPLY, None, None)
|
||||
|
||||
def handle_deactivate(self, conn, specifier, data):
|
||||
if data:
|
||||
raise ProtocolError('deactivate requests don\'t take data!')
|
||||
if specifier:
|
||||
self.unsubscribe(conn, specifier)
|
||||
else:
|
||||
self._active_connections.discard(conn)
|
||||
# XXX: also check all entries in self._subscriptions?
|
||||
return (DISABLEEVENTSREPLY, None, None)
|
||||
|
||||
def send_log_msg(self, conn, modname, level, msg):
|
||||
"""send log message """
|
||||
conn.send_reply((LOG_EVENT, '%s:%s' % (modname, level), msg))
|
||||
|
||||
def set_all_log_levels(self, conn, level):
|
||||
for modobj in self._modules.values():
|
||||
modobj.setRemoteLogging(conn, level)
|
||||
|
||||
def handle_logging(self, conn, specifier, level):
|
||||
if specifier and specifier != '.':
|
||||
modobj = self._modules[specifier]
|
||||
modobj.setRemoteLogging(conn, level)
|
||||
else:
|
||||
self.set_all_log_levels(conn, level)
|
||||
return LOGGING_REPLY, specifier, level
|
||||
52
frappy/protocol/interface/__init__.py
Normal file
52
frappy/protocol/interface/__init__.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- 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>
|
||||
#
|
||||
# *****************************************************************************
|
||||
|
||||
import json
|
||||
|
||||
EOL = b'\n'
|
||||
|
||||
|
||||
def encode_msg_frame(action, specifier=None, data=None):
|
||||
""" encode a msg_triple into an msg_frame, ready to be sent
|
||||
|
||||
action (and optional specifier) are str strings,
|
||||
data may be an json-yfied python object"""
|
||||
msg = (action, specifier or '', '' if data is None else json.dumps(data))
|
||||
return ' '.join(msg).strip().encode('utf-8') + EOL
|
||||
|
||||
|
||||
def get_msg(_bytes):
|
||||
"""try to deframe the next msg in (binary) input
|
||||
always return a tuple (msg, remaining_input)
|
||||
msg may also be None
|
||||
"""
|
||||
if EOL not in _bytes:
|
||||
return None, _bytes
|
||||
return _bytes.split(EOL, 1)
|
||||
|
||||
|
||||
def decode_msg(msg):
|
||||
"""decode the (binary) msg into a (str) msg_triple"""
|
||||
res = msg.strip().decode('utf-8').split(' ', 2) + ['', '']
|
||||
action, specifier, data = res[0:3]
|
||||
return action, specifier or None, None if data == '' else json.loads(data)
|
||||
212
frappy/protocol/interface/tcp.py
Normal file
212
frappy/protocol/interface/tcp.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# -*- 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>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""provides tcp interface to the SECoP Server"""
|
||||
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import errno
|
||||
|
||||
from frappy.datatypes import BoolType, StringType
|
||||
from frappy.errors import SECoPError
|
||||
from frappy.lib import formatException, \
|
||||
formatExtendedStack, formatExtendedTraceback
|
||||
from frappy.properties import Property
|
||||
from frappy.protocol.interface import decode_msg, encode_msg_frame, get_msg
|
||||
from frappy.protocol.messages import ERRORPREFIX, \
|
||||
HELPREPLY, HELPREQUEST, HelpMessage
|
||||
|
||||
DEF_PORT = 10767
|
||||
MESSAGE_READ_SIZE = 1024
|
||||
HELP = HELPREQUEST.encode()
|
||||
|
||||
|
||||
class TCPRequestHandler(socketserver.BaseRequestHandler):
|
||||
|
||||
def setup(self):
|
||||
self.log = self.server.log
|
||||
self.running = True
|
||||
self.send_lock = threading.Lock()
|
||||
|
||||
def handle(self):
|
||||
"""handle a new tcp-connection"""
|
||||
# copy state info
|
||||
mysocket = self.request
|
||||
clientaddr = self.client_address
|
||||
serverobj = self.server
|
||||
|
||||
self.log.info("handling new connection from %s:%d" % clientaddr)
|
||||
data = b''
|
||||
|
||||
# notify dispatcher of us
|
||||
serverobj.dispatcher.add_connection(self)
|
||||
|
||||
# copy relevant settings from Interface
|
||||
detailed_errors = serverobj.detailed_errors
|
||||
|
||||
mysocket.settimeout(1)
|
||||
# start serving
|
||||
while self.running:
|
||||
try:
|
||||
newdata = mysocket.recv(MESSAGE_READ_SIZE)
|
||||
if not newdata:
|
||||
# no timeout error, but no new data -> connection closed
|
||||
return
|
||||
data = data + newdata
|
||||
except socket.timeout:
|
||||
continue
|
||||
except socket.error as e:
|
||||
self.log.exception(e)
|
||||
return
|
||||
if not data:
|
||||
continue
|
||||
# put data into (de-) framer,
|
||||
# put frames into (de-) coder and if a message appear,
|
||||
# call dispatcher.handle_request(self, message)
|
||||
# dispatcher will queue the reply before returning
|
||||
while self.running:
|
||||
origin, data = get_msg(data)
|
||||
if origin is None:
|
||||
break # no more messages to process
|
||||
origin = origin.strip()
|
||||
if origin in (HELP, b''): # empty string -> send help message
|
||||
for idx, line in enumerate(HelpMessage.splitlines()):
|
||||
# not sending HELPREPLY here, as there should be only one reply for every request
|
||||
self.send_reply(('_', '%d' % (idx+1), line))
|
||||
# ident matches request
|
||||
self.send_reply((HELPREPLY, None, None))
|
||||
continue
|
||||
try:
|
||||
msg = decode_msg(origin)
|
||||
except Exception as err:
|
||||
# we have to decode 'origin' here
|
||||
# use latin-1, as utf-8 or ascii may lead to encoding errors
|
||||
msg = origin.decode('latin-1').split(' ', 3) + [None] # make sure len(msg) > 1
|
||||
result = (ERRORPREFIX + msg[0], msg[1], ['InternalError', str(err),
|
||||
{'exception': formatException(),
|
||||
'traceback': formatExtendedStack()}])
|
||||
print('--------------------')
|
||||
print(formatException())
|
||||
print('--------------------')
|
||||
print(formatExtendedTraceback(sys.exc_info()))
|
||||
print('====================')
|
||||
else:
|
||||
try:
|
||||
result = serverobj.dispatcher.handle_request(self, msg)
|
||||
except SECoPError as err:
|
||||
result = (ERRORPREFIX + msg[0], msg[1], [err.name, str(err),
|
||||
{'exception': formatException(),
|
||||
'traceback': formatExtendedStack()}])
|
||||
except Exception as err:
|
||||
# create Error Obj instead
|
||||
result = (ERRORPREFIX + msg[0], msg[1], ['InternalError', repr(err),
|
||||
{'exception': formatException(),
|
||||
'traceback': formatExtendedStack()}])
|
||||
print('--------------------')
|
||||
print(formatException())
|
||||
print('--------------------')
|
||||
print(formatExtendedTraceback(sys.exc_info()))
|
||||
print('====================')
|
||||
|
||||
if not result:
|
||||
self.log.error('empty result upon msg %s' % repr(msg))
|
||||
if result[0].startswith(ERRORPREFIX) and not detailed_errors:
|
||||
# strip extra information
|
||||
result[2][2].clear()
|
||||
self.send_reply(result)
|
||||
|
||||
def send_reply(self, data):
|
||||
"""send reply
|
||||
|
||||
stops recv loop on error (including timeout when output buffer full for more than 1 sec)
|
||||
"""
|
||||
if not data:
|
||||
self.log.error('should not reply empty data!')
|
||||
return
|
||||
outdata = encode_msg_frame(*data)
|
||||
with self.send_lock:
|
||||
if self.running:
|
||||
try:
|
||||
self.request.sendall(outdata)
|
||||
except Exception as e:
|
||||
self.log.error('ERROR in send_reply %r', e)
|
||||
self.running = False
|
||||
|
||||
def finish(self):
|
||||
"""called when handle() terminates, i.e. the socket closed"""
|
||||
self.log.info('closing connection from %s:%d' % self.client_address)
|
||||
# notify dispatcher
|
||||
self.server.dispatcher.remove_connection(self)
|
||||
# close socket
|
||||
try:
|
||||
self.request.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self.request.close()
|
||||
|
||||
|
||||
class TCPServer(socketserver.ThreadingTCPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
# for cfg-editor
|
||||
configurables = {
|
||||
'uri': Property('hostname or ip address for binding', StringType(),
|
||||
default='tcp://%d' % DEF_PORT, export=False),
|
||||
'detailed_errors': Property('Flag to enable detailed Errorreporting.', BoolType(),
|
||||
default=False, export=False),
|
||||
}
|
||||
|
||||
def __init__(self, name, logger, options, srv):
|
||||
self.dispatcher = srv.dispatcher
|
||||
self.name = name
|
||||
self.log = logger
|
||||
port = int(options.pop('uri').split('://', 1)[-1])
|
||||
self.detailed_errors = options.pop('detailed_errors', False)
|
||||
|
||||
self.log.info("TCPServer %s binding to port %d" % (name, port))
|
||||
for ntry in range(5):
|
||||
try:
|
||||
socketserver.ThreadingTCPServer.__init__(
|
||||
self, ('0.0.0.0', port), TCPRequestHandler, bind_and_activate=True)
|
||||
break
|
||||
except OSError as e:
|
||||
if e.args[0] == errno.EADDRINUSE: # address already in use
|
||||
# this may happen despite of allow_reuse_address
|
||||
time.sleep(0.3 * (1 << ntry)) # max accumulated sleep time: 0.3 * 31 = 9.3 sec
|
||||
else:
|
||||
self.log.error('could not initialize TCP Server: %r' % e)
|
||||
raise
|
||||
if ntry:
|
||||
self.log.warning('tried again %d times after "Address already in use"' % ntry)
|
||||
self.log.info("TCPServer initiated")
|
||||
|
||||
# py35 compatibility
|
||||
if not hasattr(socketserver.ThreadingTCPServer, '__exit__'):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.server_close()
|
||||
28
frappy/protocol/interface/zmq.py
Normal file
28
frappy/protocol/interface/zmq.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- 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>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""provide a zmq server"""
|
||||
|
||||
|
||||
# tbd.
|
||||
|
||||
# use zmq frames??
|
||||
# handle async and sync with different zmq ports?
|
||||
104
frappy/protocol/messages.py
Normal file
104
frappy/protocol/messages.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# -*- 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 SECoP Messages"""
|
||||
|
||||
# allowed actions:
|
||||
|
||||
IDENTREQUEST = '*IDN?' # literal
|
||||
# literal! first part is fixed!
|
||||
IDENTPREFIX = 'SINE2020&ISSE,SECoP,'
|
||||
IDENTREPLY = IDENTPREFIX + 'V2019-08-20,v1.0 RC2'
|
||||
|
||||
DESCRIPTIONREQUEST = 'describe' # literal
|
||||
DESCRIPTIONREPLY = 'describing' # +<id> +json
|
||||
|
||||
ENABLEEVENTSREQUEST = 'activate' # literal + optional spec
|
||||
ENABLEEVENTSREPLY = 'active' # literal + optional spec, is end-of-initial-data-transfer
|
||||
|
||||
DISABLEEVENTSREQUEST = 'deactivate' # literal + optional spec
|
||||
DISABLEEVENTSREPLY = 'inactive' # literal + optional spec
|
||||
|
||||
COMMANDREQUEST = 'do' # +module:command +json args (if needed)
|
||||
# +module:command +json args (if needed) # send after the command finished !
|
||||
COMMANDREPLY = 'done'
|
||||
|
||||
# +module[:parameter] +json_value
|
||||
WRITEREQUEST = 'change'
|
||||
# +module[:parameter] +json_value # send with the read back value
|
||||
WRITEREPLY = 'changed'
|
||||
|
||||
# +module[:parameter] +json_value
|
||||
BUFFERREQUEST = 'buffer'
|
||||
# +module[:parameter] +json_value # send with the read back value
|
||||
BUFFERREPLY = 'buffered'
|
||||
|
||||
# +module[:parameter] -> NO direct reply, calls POLL internally!
|
||||
READREQUEST = 'read'
|
||||
READREPLY = 'reply' # See Issue 54
|
||||
|
||||
EVENTREPLY = 'update' # +module[:parameter] +json_value (value, qualifiers_as_dict)
|
||||
|
||||
HEARTBEATREQUEST = 'ping' # +nonce_without_space
|
||||
HEARTBEATREPLY = 'pong' # +nonce_without_space
|
||||
|
||||
ERRORPREFIX = 'error_' # + specifier + json_extended_info(error_report)
|
||||
|
||||
HELPREQUEST = 'help' # literal
|
||||
HELPREPLY = 'helping' # +line number +json_text
|
||||
|
||||
LOGGING_REQUEST = 'logging'
|
||||
LOGGING_REPLY = 'logging'
|
||||
# + [module] + json string (loglevel)
|
||||
|
||||
LOG_EVENT = 'log'
|
||||
# + [module:level] + json_string (message)
|
||||
|
||||
# helper mapping to find the REPLY for a REQUEST
|
||||
# do not put IDENTREQUEST/IDENTREPLY here, as this needs anyway extra treatment
|
||||
REQUEST2REPLY = {
|
||||
DESCRIPTIONREQUEST: DESCRIPTIONREPLY,
|
||||
ENABLEEVENTSREQUEST: ENABLEEVENTSREPLY,
|
||||
DISABLEEVENTSREQUEST: DISABLEEVENTSREPLY,
|
||||
COMMANDREQUEST: COMMANDREPLY,
|
||||
WRITEREQUEST: WRITEREPLY,
|
||||
BUFFERREQUEST: BUFFERREPLY,
|
||||
READREQUEST: READREPLY,
|
||||
HEARTBEATREQUEST: HEARTBEATREPLY,
|
||||
HELPREQUEST: HELPREPLY,
|
||||
LOGGING_REQUEST: LOGGING_REPLY,
|
||||
}
|
||||
|
||||
|
||||
HelpMessage = """Try one of the following:
|
||||
'%s' to query protocol version
|
||||
'%s' to read the description
|
||||
'%s <module>[:<parameter>]' to request reading a value
|
||||
'%s <module>[:<parameter>] value' to request changing a value
|
||||
'%s <module>[:<command>]' to execute a command
|
||||
'%s <nonce>' to request a heartbeat response
|
||||
'%s' to activate async updates
|
||||
'%s' to deactivate updates
|
||||
'%s [<module>] <loglevel>' to activate logging events
|
||||
""" % (IDENTREQUEST, DESCRIPTIONREQUEST, READREQUEST,
|
||||
WRITEREQUEST, COMMANDREQUEST, HEARTBEATREQUEST,
|
||||
ENABLEEVENTSREQUEST, DISABLEEVENTSREQUEST,
|
||||
LOGGING_REQUEST)
|
||||
190
frappy/protocol/router.py
Normal file
190
frappy/protocol/router.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# -*- 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>
|
||||
#
|
||||
# *****************************************************************************
|
||||
"""Secop Router
|
||||
|
||||
this is a replacement for the standard dispatcher, with the
|
||||
additional functionality of routing message from/to several other SEC nodes
|
||||
|
||||
simplifications:
|
||||
- module wise activation not supported
|
||||
- on connection, the description from all nodes are cached and all nodes are activated
|
||||
- on 'describe' and on 'activate', cached values are returned
|
||||
- ping is not forwarded
|
||||
- what to do on a change of descriptive data is not yet implemented
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import frappy.client
|
||||
import frappy.errors
|
||||
import frappy.protocol.dispatcher
|
||||
from frappy.lib.multievent import MultiEvent
|
||||
from frappy.protocol.messages import COMMANDREQUEST, DESCRIPTIONREPLY, \
|
||||
ENABLEEVENTSREPLY, ERRORPREFIX, EVENTREPLY, READREQUEST, WRITEREQUEST
|
||||
|
||||
|
||||
class SecopClient(frappy.client.SecopClient):
|
||||
disconnectedExc = frappy.errors.CommunicationFailedError('remote SEC node disconnected')
|
||||
disconnectedError = (disconnectedExc.name, str(disconnectedExc))
|
||||
|
||||
def __init__(self, uri, log, dispatcher):
|
||||
self.dispatcher = dispatcher
|
||||
super().__init__(uri, log)
|
||||
|
||||
def internalize_name(self, name):
|
||||
"""do not modify names"""
|
||||
return name
|
||||
|
||||
def updateEvent(self, module, parameter, value, timestamp, readerror):
|
||||
specifier = '%s:%s' % (module, parameter)
|
||||
if readerror:
|
||||
msg = ERRORPREFIX + EVENTREPLY, specifier, (readerror.name, str(readerror), dict(t=timestamp))
|
||||
else:
|
||||
msg = EVENTREPLY, specifier, (value, dict(t=timestamp))
|
||||
self.dispatcher.broadcast_event(msg)
|
||||
|
||||
def nodeStateChange(self, online, state):
|
||||
t = time.time()
|
||||
if not online:
|
||||
for key, (value, _, readerror) in self.cache.items():
|
||||
if not readerror:
|
||||
self.cache[key] = value, t, self.disconnectedExc
|
||||
self.updateEvent(*key, *self.cache[key])
|
||||
|
||||
def descriptiveDataChange(self, module, data):
|
||||
if module is None:
|
||||
self.dispatcher.restart()
|
||||
self._shutdown = True
|
||||
raise frappy.errors.SECoPError('descriptive data for node %r has changed' % self.nodename)
|
||||
|
||||
|
||||
class Router(frappy.protocol.dispatcher.Dispatcher):
|
||||
singlenode = None
|
||||
|
||||
def __init__(self, name, logger, options, srv):
|
||||
"""initialize router
|
||||
|
||||
Use the option node = <uri> for a single node or
|
||||
nodes = ["<uri1>", "<uri2>" ...] for multiple nodes.
|
||||
If a single node is given, the node properties are forwarded transparently,
|
||||
else the description property is a merge from all client node properties.
|
||||
"""
|
||||
uri = options.pop('node', None)
|
||||
uris = options.pop('nodes', None)
|
||||
if uri and uris:
|
||||
raise frappy.errors.ConfigError('can not specify node _and_ nodes')
|
||||
super().__init__(name, logger, options, srv)
|
||||
if uri:
|
||||
self.nodes = [SecopClient(uri, logger.getChild('routed'), self)]
|
||||
self.singlenode = self.nodes[0]
|
||||
else:
|
||||
self.nodes = [SecopClient(uri, logger.getChild('routed%d' % i), self) for i, uri in enumerate(uris)]
|
||||
# register callbacks
|
||||
for node in self.nodes:
|
||||
node.register_callback(None, node.updateEvent, node.descriptiveDataChange, node.nodeStateChange)
|
||||
|
||||
self.restart = srv.restart
|
||||
self.node_by_module = {}
|
||||
multievent = MultiEvent()
|
||||
for node in self.nodes:
|
||||
node.spawn_connect(multievent.new().set)
|
||||
multievent.wait(10) # wait for all nodes started
|
||||
nodes = []
|
||||
for node in self.nodes:
|
||||
if node.online:
|
||||
for module in node.modules:
|
||||
self.node_by_module[module] = node
|
||||
nodes.append(node)
|
||||
else:
|
||||
|
||||
def nodeStateChange(online, state, self=self, node=node):
|
||||
if online:
|
||||
for module in node.modules:
|
||||
self.node_by_module[module] = node
|
||||
self.nodes.append(node)
|
||||
self.restart()
|
||||
return frappy.client.UNREGISTER
|
||||
return None
|
||||
|
||||
node.register_callback(None, nodeStateChange)
|
||||
logger.warning('can not connect to node %r', node.nodename)
|
||||
|
||||
def handle_describe(self, conn, specifier, data):
|
||||
if self.singlenode:
|
||||
return DESCRIPTIONREPLY, specifier, self.singlenode.descriptive_data
|
||||
reply = super().handle_describe(conn, specifier, data)
|
||||
result = reply[2]
|
||||
allmodules = result.get('modules', {})
|
||||
node_description = [result['description']]
|
||||
for node in self.nodes:
|
||||
data = node.descriptive_data.copy()
|
||||
modules = data.pop('modules')
|
||||
equipment_id = data.pop('equipment_id', 'unknown')
|
||||
node_description.append('--- %s ---\n%s' % (equipment_id, data.pop('description', '')))
|
||||
node_description.append('\n'.join('%s: %r' % kv for kv in data.items()))
|
||||
for modname, moddesc in modules.items():
|
||||
if modname in allmodules:
|
||||
self.log.info('module %r is already present', modname)
|
||||
else:
|
||||
allmodules[modname] = moddesc
|
||||
result['modules'] = allmodules
|
||||
result['description'] = '\n\n'.join(node_description)
|
||||
return DESCRIPTIONREPLY, specifier, result
|
||||
|
||||
def handle_activate(self, conn, specifier, data):
|
||||
super().handle_activate(conn, specifier, data)
|
||||
for node in self.nodes:
|
||||
for (module, parameter), (value, t, readerror) in node.cache.items():
|
||||
spec = '%s:%s' % (module, parameter)
|
||||
if readerror:
|
||||
reply = ERRORPREFIX + EVENTREPLY, spec, (readerror.name, str(readerror), dict(t=t))
|
||||
else:
|
||||
datatype = node.modules[module]['parameters'][parameter]['datatype']
|
||||
reply = EVENTREPLY, spec, [datatype.export_value(value), dict(t=t)]
|
||||
self.broadcast_event(reply)
|
||||
return ENABLEEVENTSREPLY, None, None
|
||||
|
||||
def handle_deactivate(self, conn, specifier, data):
|
||||
if specifier:
|
||||
raise frappy.errors.NotImplementedError('module wise activation not implemented')
|
||||
super().handle_deactivate(conn, specifier, data)
|
||||
|
||||
def handle_read(self, conn, specifier, data):
|
||||
module = specifier.split(':')[0]
|
||||
if module in self._modules:
|
||||
return super().handle_read(conn, specifier, data)
|
||||
node = self.node_by_module[module]
|
||||
if node.online:
|
||||
return node.request(READREQUEST, specifier, data)
|
||||
return ERRORPREFIX + READREQUEST, specifier, SecopClient.disconnectedError + (dict(t=node.disconnect_time),)
|
||||
|
||||
def handle_change(self, conn, specifier, data):
|
||||
module = specifier.split(':')[0]
|
||||
if module in self._modules:
|
||||
return super().handle_change(conn, specifier, data)
|
||||
return self.node_by_module[module].request(WRITEREQUEST, specifier, data)
|
||||
|
||||
def handle_do(self, conn, specifier, data):
|
||||
module = specifier.split(':')[0]
|
||||
if module in self._modules:
|
||||
return super().handle_do(conn, specifier, data)
|
||||
return self.node_by_module[module].request(COMMANDREQUEST, specifier, data)
|
||||
Reference in New Issue
Block a user