# -*- 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 # # ***************************************************************************** """Define Client side proxies""" import json import queue import socket import threading import time from collections import OrderedDict from select import select import mlzlog 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(object): # disguise a TCP connection as serial one def __init__(self, host, port): self.log = mlzlog.getLogger('TCPConnection') self._host = host self._port = int(port) self._thread = None self.callbacks = [] # called if SEC-node shuts down self.connect() def connect(self): self._readbuffer = queue.Queue(100) 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): 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(object): 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(object): secop_id = 'unknown' describing_data = {} stopflag = False connection_established = False def __init__(self, opts, autoconnect=True): if 'testing' not in opts: self.log = mlzlog.log.getChild('client', True) else: class logStub(object): 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) 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])) 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()): 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_class'] 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])