diff --git a/bin/server.py b/bin/server.py index 996acc8..160a2cd 100755 --- a/bin/server.py +++ b/bin/server.py @@ -33,35 +33,35 @@ pid_path = path.join(basepath, 'pid') log_path = path.join(basepath, 'log') sys.path[0] = path.join(basepath, 'src') - +import logger import argparse from lib import check_pidfile, start_server, kill_server -parser = argparse.ArgumentParser(description = "Manage a SECoP server") +parser = argparse.ArgumentParser(description="Manage a SECoP server") loggroup = parser.add_mutually_exclusive_group() -loggroup.add_argument("-v", "--verbose", help="Output lots of diagnostic information", - action='store_true', default=False) -loggroup.add_argument("-q", "--quiet", help="suppress non-error messages", action='store_true', - default=False) -parser.add_argument("action", help="What to do with the server: (re)start, status or stop", - choices=['start', 'status', 'stop', 'restart'], default="status") -parser.add_argument("name", help="Name of the instance. Uses etc/name.cfg for configuration\n" +loggroup.add_argument("-v", "--verbose", + help="Output lots of diagnostic information", + action='store_true', default=False) +loggroup.add_argument("-q", "--quiet", help="suppress non-error messages", + action='store_true', default=False) +parser.add_argument("action", + help="What to do: (re)start, status or stop", + choices=['start', 'status', 'stop', 'restart'], + default="status") +parser.add_argument("name", + help="Name of the instance.\n" + " Uses etc/name.cfg for configuration\n" "may be omitted to mean ALL (which are configured)", nargs='?', default='') args = parser.parse_args() -import logging -loglevel = logging.DEBUG if args.verbose else (logging.ERROR if args.quiet else logging.INFO) -logging.basicConfig(level=loglevel, format='%(asctime)s %(levelname)s %(message)s') -logger = logging.getLogger('server') -logger.setLevel(loglevel) -fh = logging.FileHandler(path.join(log_path, 'server.log'), 'w') -fh.setLevel(loglevel) -logger.addHandler(fh) +loglevel = 'debug' if args.verbose else ('error' if args.quiet else 'info') +logger = logger.get_logger('startup', loglevel) logger.debug("action specified %r" % args.action) + def handle_servername(name, action): pidfile = path.join(pid_path, name + '.pid') cfgfile = path.join(etc_path, name + '.cfg') @@ -97,7 +97,7 @@ if not args.name: if fn.endswith('.cfg'): handle_servername(fn[:-4], args.action) else: - logger.debug('configfile with strange extension found: %r' + logger.debug('configfile with strange extension found: %r' % path.basename(fn)) # ignore subdirs! while(dirs): diff --git a/etc/test.cfg b/etc/test.cfg index b3f307b..f30bb51 100644 --- a/etc/test.cfg +++ b/etc/test.cfg @@ -1,7 +1,9 @@ [server] bindto=localhost bindport=10767 -protocol=pickle +interface = tcp +framing=eol +encoding=text [device LN2] class=devices.test.LN2 diff --git a/src/client/__init__.py b/src/client/__init__.py index e69de29..3f5ca10 100644 --- a/src/client/__init__.py +++ b/src/client/__init__.py @@ -0,0 +1,38 @@ +# -*- 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""" + +# nothing here yet. + + +def get_client(interfacespec): + """returns a client connected to the remote interface""" + pass + + +class DeviceProxy(object): + """(In python) dynamically constructed object + + allowing access to the servers devices via the SECoP Protocol inbetween + """ + pass diff --git a/src/devices/core.py b/src/devices/core.py index e081e87..605754e 100644 --- a/src/devices/core.py +++ b/src/devices/core.py @@ -22,38 +22,46 @@ """Define Baseclasses for real devices implemented in the server""" +# XXX: connect with 'protocol'-Devices. +# Idea: every Device defined herein is also a 'protocol'-device, +# all others MUST derive from those, the 'interface'-class is still derived +# from these base classes (how to do this?) + import types import inspect from errors import ConfigError, ProgrammingError from protocol import status -# storage for CONFIGurable settings (from configfile) -class CONFIG(object): - def __init__(self, description, validator=None, default=None, unit=None): - self.description = description - self.validator = validator - self.default = default - self.unit = unit - -# storage for PARAMeter settings (changeable during runtime) +# storage for PARAMeter settings: +# if readonly is False, the value can be changed (by code, or remte) +# if no default is given, the parameter MUST be specified in the configfile +# during startup, currentvalue is initialized with the default value or +# from the config file class PARAM(object): - def __init__(self, description, validator=None, default=None, unit=None, readonly=False): + def __init__(self, description, validator=None, default=Ellipsis, + unit=None, readonly=False, export=True): self.description = description self.validator = validator self.default = default self.unit = unit self.readonly = readonly + self.export = export # internal caching... self.currentvalue = default -# storage for CMDs settings (names + call signature...) +# storage for CMDs settings (description + call signature...) class CMD(object): - def __init__(self, description, *args): + def __init__(self, description, arguments, result): + # descriptive text for humans self.description = description - self.arguments = args + # list of validators for arguments + self.argumenttype = arguments + # validator for results + self.resulttype = result + # Meta class # warning: MAGIC! @@ -62,25 +70,34 @@ class DeviceMeta(type): newtype = type.__new__(mcs, name, bases, attrs) if '__constructed__' in attrs: return newtype - # merge CONFIG, PARAM, CMDS from all sub-classes - for entry in ['CONFIG', 'PARAMS', 'CMDS']: + + # merge PARAM and CMDS from all sub-classes + for entry in ['PARAMS', 'CMDS']: newentry = {} for base in reversed(bases): if hasattr(base, entry): newentry.update(getattr(base, entry)) newentry.update(attrs.get(entry, {})) setattr(newtype, entry, newentry) - # check validity of entries - for cname, info in newtype.CONFIG.items(): - if not isinstance(info, CONFIG): - raise ProgrammingError("%r: device CONFIG %r should be a CONFIG object!" % - (name, cname)) - #XXX: greate getters for the config value + + # check validity of PARAM entries for pname, info in newtype.PARAMS.items(): if not isinstance(info, PARAM): - raise ProgrammingError("%r: device PARAM %r should be a PARAM object!" % - (name, pname)) + raise ProgrammingError('%r: device PARAM %r should be a ' + 'PARAM object!' % (name, pname)) #XXX: greate getters and setters, setters should send async updates + + def getter(): + return self.PARAMS[pname].currentvalue + + def setter(value): + p = self.PARAMS[pname] + p.currentvalue = p.validator(value) if p.validator else value + # also send notification + self.DISPATCHER.announce_update(self, pname, value) + + attrs[pname] = property(getter, setter) + # also collect/update information about CMD's setattr(newtype, 'CMDS', getattr(newtype, 'CMDS', {})) for name in attrs: @@ -90,55 +107,56 @@ class DeviceMeta(type): argspec = inspect.getargspec(value) if argspec[0] and argspec[0][0] == 'self': del argspec[0][0] - newtype.CMDS[name] = CMD(value.get('__doc__', name), *argspec) + newtype.CMDS[name] = CMD(value.get('__doc__', name), + *argspec) attrs['__constructed__'] = True return newtype + # Basic device class class Device(object): """Basic Device, doesn't do much""" __metaclass__ = DeviceMeta - # CONFIG, PARAMS and CMDS are auto-merged upon subclassing - CONFIG = {} + # PARAMS and CMDS are auto-merged upon subclassing PARAMS = {} CMDS = {} - SERVER = None - def __init__(self, devname, serverobj, logger, cfgdict): + DISPATCHER = None + + def __init__(self, logger, cfgdict, devname, dispatcher): # remember the server object (for the async callbacks) - self.SERVER = serverobj + self.DISPATCHER = dispatcher self.log = logger self.name = devname # check config for problems - # only accept config items specified in CONFIG + # only accept config items specified in PARAMS for k, v in cfgdict.items(): - if k not in self.CONFIG: - raise ConfigError('Device %s:config Parameter %r not unterstood!' % (self.name, k)) - # complain if a CONFIG entry has no default value and is not specified in cfgdict - for k, v in self.CONFIG.items(): + if k not in self.PARAMS: + raise ConfigError('Device %s:config Parameter %r ' + 'not unterstood!' % (self.name, k)) + # complain if a PARAM entry has no default value and + # is not specified in cfgdict + for k, v in self.PARAMS.items(): if k not in cfgdict: - if 'default' not in v: - raise ConfigError('Config Parameter %r was not given and not default value exists!' % k) - cfgdict[k] = v['default'] # assume default value was given. - # now 'apply' config, passing values through the validators and store as attributes + if v.default is Ellipsis: + # Ellipsis is the one single value you can not specify.... + raise ConfigError('Device %s: Parameter %r has no default ' + 'value and was not given in config!' + % (self.name, k)) + # assume default value was given + cfgdict[k] = v.default + # now 'apply' config: + # pass values through the validators and store as attributes for k, v in cfgdict.items(): # apply validator, complain if type does not fit - validator = self.CONFIG[k].validator + validator = self.PARAMS[k].validator if validator is not None: # only check if validator given try: v = validator(v) except ValueError as e: - raise ConfigError("Device %s: config paramter %r:\n%r" % (self.name, k, e)) + raise ConfigError('Device %s: config parameter %r:\n%r' + % (self.name, k, e)) # XXX: with or without prefix? - setattr(self, 'config_' + k, v) - # set default parameter values as inital values - for k, v in self.PARAMS.items(): - # apply validator, complain if type does not fit - validator = v.validator - value = v.default - if validator is not None: - # only check if validator given - value = validator(value) setattr(self, k, v) def init(self): @@ -147,12 +165,16 @@ class Device(object): class Readable(Device): - """Basic readable device, providing the RO parameter 'value' and 'status'""" + """Basic readable device + + providing the readonly parameter 'value' and 'status' + """ PARAMS = { - 'value' : PARAM('current value of the device', readonly=True), - 'status' : PARAM('current status of the device', - readonly=True), + 'value': PARAM('current value of the device', readonly=True, default=0.), + 'status': PARAM('current status of the device', default=status.OK, + readonly=True), } + def read_value(self, maxage=0): raise NotImplementedError @@ -161,10 +183,13 @@ class Readable(Device): class Driveable(Readable): - """Basic Driveable device, providing a RW target parameter to those of a Readable""" + """Basic Driveable device + + providing a settable 'target' parameter to those of a Readable + """ PARAMS = { - 'target' : PARAM('target value of the device'), + 'target': PARAM('target value of the device', default=0.), } + def write_target(self, value): raise NotImplementedError - diff --git a/src/devices/cryo.py b/src/devices/cryo.py index 9bd96d4..b007cea 100644 --- a/src/devices/cryo.py +++ b/src/devices/cryo.py @@ -32,62 +32,65 @@ from validators import floatrange, positive, mapping from lib import clamp -hack = [] - class Cryostat(Driveable): - """simulated cryostat with heat capacity on the sample, cooling power and thermal transfer functions""" - CONFIG = dict( + """simulated cryostat with: + + - heat capacity of the sample + - cooling power + - thermal transfer between regulation and samplen + """ + PARAMS = dict( jitter=CONFIG("amount of random noise on readout values", - validator=floatrange(0, 1), default=1, - ), - T_start=CONFIG("starting temperature for simulation", - validator=positive, default=2, + validator=floatrange(0, 1), + export=False, ), + T_start=CONFIG("starting temperature for simulation", + validator=positive, export=False, + ), looptime=CONFIG("timestep for simulation", validator=positive, default=1, unit="s", - ), - ) - PARAMS = dict( + export=False, + ), ramp=PARAM("ramping speed in K/min", validator=floatrange(0, 1e3), default=1, - ), + ), setpoint=PARAM("ramping speed in K/min", validator=float, default=1, readonly=True, - ), + ), maxpower=PARAM("Maximum heater power in W", validator=float, default=0, readonly=True, unit="W", - ), + ), heater=PARAM("current heater setting in %", validator=float, default=0, readonly=True, unit="%", - ), + ), heaterpower=PARAM("current heater power in W", validator=float, default=0, readonly=True, unit="W", - ), + ), target=PARAM("target temperature in K", validator=float, default=0, unit="K", - ), + ), p=PARAM("regulation coefficient 'p' in %/K", validator=positive, default=40, unit="%/K", - ), + ), i=PARAM("regulation coefficient 'i'", validator=floatrange(0, 100), default=10, - ), + ), d=PARAM("regulation coefficient 'd'", validator=floatrange(0, 100), default=2, - ), + ), mode=PARAM("mode of regulation", validator=mapping('ramp', 'pid', 'openloop'), default='pid', - ), + ), tolerance=PARAM("temperature range for stability checking", - validator=floatrange(0, 100), default=0.1, unit='K', - ), + validator=floatrange(0, 100), default=0.1, unit='K', + ), window=PARAM("time window for stability checking", validator=floatrange(1, 900), default=30, unit='s', - ), + ), timeout=PARAM("max waiting time for stabilisation check", - validator=floatrange(1, 36000), default=900, unit='s', - ), + validator=floatrange(1, 36000), default=900, unit='s', + ), ) def init(self): @@ -95,16 +98,15 @@ class Cryostat(Driveable): self._thread = threading.Thread(target=self.thread) self._thread.daemon = True self._thread.start() - #XXX: hack!!! use a singleton as registry for the other devices to access this one... - hack.append(self) def read_status(self): - # instead of asking a 'Hardware' take the value from the simulation thread + # instead of asking a 'Hardware' take the value from the simulation return self.status def read_value(self, maxage=0): # return regulation value (averaged regulation temp) - return self.regulationtemp + self.config_jitter * (0.5 - random.random()) + return self.regulationtemp + \ + self.config_jitter * (0.5 - random.random()) def read_target(self, maxage=0): return self.target @@ -119,12 +121,13 @@ class Cryostat(Driveable): def write_maxpower(self, newpower): # rescale heater setting in % to keep the power - self.heater = max(0, min(100, self.heater * self.maxpower / float(newpower))) + heat = max(0, min(100, self.heater * self.maxpower / float(newpower))) + self.heater = heat self.maxpower = newpower def doStop(self): - # stop the ramp by setting current value as target - # XXX: there may be use case where setting the current temp may be better + # stop the ramp by setting current setpoint as target + # XXX: discussion: take setpoint or current value ??? self.write_target(self.setpoint) # @@ -176,7 +179,8 @@ class Cryostat(Driveable): # local state keeping: regulation = self.regulationtemp sample = self.sampletemp - window = [] # keep history values for stability check + # keep history values for stability check + window = [] timestamp = time.time() heater = 0 lastflow = 0 @@ -210,7 +214,8 @@ class Cryostat(Driveable): newregulation = max(0, regulation + regdelta / self.__coolerCP(regulation) * h) # b) see - # http://brettbeauregard.com/blog/2011/04/improving-the-beginners-pid-introduction/ + # http://brettbeauregard.com/blog/2011/04/ + # improving-the-beginners-pid-introduction/ if self.mode != 'openloop': # fix artefacts due to too big timesteps # actually i would prefer reducing looptime, but i have no @@ -320,4 +325,3 @@ class Cryostat(Driveable): self._stopflag = True if self._thread and self._thread.isAlive(): self._thread.join() - diff --git a/src/devices/test.py b/src/devices/test.py index d4339af..c429868 100644 --- a/src/devices/test.py +++ b/src/devices/test.py @@ -23,42 +23,51 @@ import random -from devices.core import Readable, Driveable, CONFIG, PARAM +from devices.core import Readable, Driveable, PARAM from validators import floatrange + class LN2(Readable): """Just a readable. - class name indicates it to be a sensor for LN2, but the implementation may do anything""" + class name indicates it to be a sensor for LN2, + but the implementation may do anything + """ def read_value(self, maxage=0): return round(100*random.random(), 1) + class Heater(Driveable): """Just a driveable. - class name indicates it to be some heating element, but the implementation may do anything""" - CONFIG = { - 'maxheaterpower' : CONFIG('maximum allowed heater power', - validator=floatrange(0, 100), unit='W'), + class name indicates it to be some heating element, + but the implementation may do anything + """ + PARAMS = { + 'maxheaterpower': PARAM('maximum allowed heater power', + validator=floatrange(0, 100), unit='W'), } + def read_value(self, maxage=0): return round(100*random.random(), 1) def write_target(self, target): pass + class Temp(Driveable): """Just a driveable. - class name indicates it to be some temperature controller, but the implementation may do anything""" - CONFIG = { - 'sensor' : CONFIG("Sensor number or calibration id", - validator=str), + class name indicates it to be some temperature controller, + but the implementation may do anything + """ + PARAMS = { + 'sensor': PARAM("Sensor number or calibration id", + validator=str, readonly=True), } + def read_value(self, maxage=0): return round(100*random.random(), 1) def write_target(self, target): pass - - diff --git a/src/errors.py b/src/errors.py index 5360681..8600fd9 100644 --- a/src/errors.py +++ b/src/errors.py @@ -22,11 +22,14 @@ # ***************************************************************************** """error class for our little framework""" + class SECoPServerError(Exception): pass + class ConfigError(SECoPServerError): pass + class ProgrammingError(SECoPServerError): pass diff --git a/src/lib/__init__.py b/src/lib/__init__.py index 07bc3a5..26a9281 100644 --- a/src/lib/__init__.py +++ b/src/lib/__init__.py @@ -22,16 +22,17 @@ """Define helpers""" -import logging -from os import path class attrdict(dict): """a normal dict, providing access also via attributes""" + def __getattr__(self, key): return self[key] + def __setattr__(self, key, value): self[key] = value + def clamp(_min, value, _max): """return the median of 3 values, @@ -41,26 +42,15 @@ def clamp(_min, value, _max): # return median, i.e. clamp the the value between min and max return sorted([_min, value, _max])[1] + def get_class(spec): """loads a class given by string in dotted notaion (as python would do)""" modname, classname = spec.rsplit('.', 1) import importlib -# module = importlib.import_module(modname) - module = __import__(spec) + module = importlib.import_module(modname) +# module = __import__(spec) return getattr(module, classname) -def make_logger(inst='server', name='', base_path='', loglevel=logging.INFO): - # XXX: rework this! (outsource to a logging module...) - if name: - inst = '%s %s' % (inst, name) - logging.basicConfig(level=loglevel, format='%(asctime)s %(levelname)s %(message)s') - logger = logging.getLogger(inst) - logger.setLevel(loglevel) - fh = logging.FileHandler(path.join(base_path, 'log', (name or inst) + '.log')) - fh.setLevel(loglevel) - logger.addHandler(fh) - return logger - # moved below definitions to break import cycle from pidfile import * @@ -73,4 +63,3 @@ if __name__ == '__main__': d.c = 9 d['d'] = 'c' assert d[d.d] == 9 - diff --git a/src/lib/pidfile.py b/src/lib/pidfile.py index 4aac509..f5ebb6a 100644 --- a/src/lib/pidfile.py +++ b/src/lib/pidfile.py @@ -33,21 +33,23 @@ def read_pidfile(pidfile): try: with open(pidfile, 'r') as f: return int(f.read()) - except OSError: + except (OSError, IOError): return None + def remove_pidfile(pidfile): """remove the given pidfile, typically at end of the process""" os.remove(pidfile) + def write_pidfile(pidfile, pid): """write the given pid to the given pidfile""" with open(pidfile, 'w') as f: f.write('%d\n' % pid) atexit.register(remove_pidfile, pidfile) + def check_pidfile(pidfile): """check if the process from a given pidfile is still running""" pid = read_pidfile(pidfile) return False if pid is None else psutil.pid_exists(pid) - diff --git a/src/lib/startup.py b/src/lib/startup.py deleted file mode 100644 index 4708919..0000000 --- a/src/lib/startup.py +++ /dev/null @@ -1,133 +0,0 @@ -# -*- 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 helpers""" -import os -import psutil -import daemonize -import ConfigParser - -from lib import read_pidfile, write_pidfile, get_class, make_logger -from server import DeviceServer as Server -from errors import ConfigError - -__ALL__ = ['kill_server', 'start_server'] - -def kill_server(pidfile): - """kill a server specified by a pidfile""" - pid = read_pidfile(pidfile) - if pid is None: - # already dead/not started yet - return - # get process for this pid - for proc in psutil.process_iter(): - if proc.pid == pid: - break - proc.terminate() - proc.wait(3) - proc.kill() - -def start_server(srvname, base_path, loglevel, daemon=False): - """start a server, part1 - - handle the daemonizing and logging stuff and call the second step - """ - pidfile = os.path.join(base_path, 'pid', srvname + '.pid') - if daemon: -# dysfunctional :( - daemonproc = daemonize.Daemonize("server %s" % srvname, - pid=pidfile, - action=lambda: startup(srvname, base_path, loglevel), - ) - daemonproc.start() - - - else: - write_pidfile(pidfile, os.getpid()) - startup(srvname, base_path, loglevel) # blocks! - -# unexported stuff here -def startup(srvname, base_path, loglevel): - """really start a server (part2) - - loads the config, initiate all objects, link them together - and finally start the interface server. - Never returns. (may raise) - """ - cfgfile = os.path.join(base_path, 'etc', srvname + '.cfg') - - logger = make_logger('server', srvname, base_path=base_path, loglevel=loglevel) - logger.debug("parsing %r" % cfgfile) - - parser = ConfigParser.SafeConfigParser() - if not parser.read([cfgfile]): - logger.error("Couldn't read cfg file !") - raise ConfigError("Couldn't read cfg file %r" % cfgfile) - - # evaluate Server specific stuff - if not parser.has_section('server'): - logger.error("cfg file needs a 'server' section!") - raise ConfigError("cfg file %r needs a 'server' section!" % cfgfile) - serveropts = dict(item for item in parser.items('server')) - - # check serveropts (init server) - # this raises if something wouldn't work - logger.debug("Creating device server") - server = Server(logger, serveropts) - - # iterate over all sections, checking for devices - deviceopts = [] - for section in parser.sections(): - if section == "server": - continue # already handled, see above - if section.lower().startswith("device"): - # device section - devname = section[len('device '):] # omit leading 'device ' string - devopts = dict(item for item in parser.items(section)) - if 'class' not in devopts: - logger.error("Device %s needs a class option!") - raise ConfigError("cfgfile %r: Device %s needs a class option!" % (cfgfile, devname)) - # try to import the class, raise if this fails - devopts['class'] = get_class(devopts['class']) - # all went well so far - deviceopts.append([devname, devopts]) - - # check devices by creating them - devs = {} - for devname, devopts in deviceopts: - devclass = devopts.pop('class') - # create device - logger.debug("Creating Device %r" % devname) - devobj = devclass(devname, server, logger, devopts) - devs[devname] = devobj - - # connect devices with server - for devname, devobj in devs.items(): - logger.info("registering device %r" % devname) - server.register_device(devobj, devname) - # also call init on the devices - logger.debug("device.init()") - devobj.init() - - # handle requests until stop is requsted - logger.info('startup done, handling transport messages') - server.serve_forever() diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..55fb183 --- /dev/null +++ b/src/logger.py @@ -0,0 +1,47 @@ +# -*- 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 +# +# ***************************************************************************** + +"""Loggers""" + +import logging +from os import path + +from paths import log_path + + +def get_logger(inst='', loglevel=logging.INFO): + loglevelmap = {'debug': logging.DEBUG, + 'info': logging.INFO, + 'warning': logging.WARNING, + 'error': logging.ERROR, + } + loglevel = loglevelmap.get(loglevel, loglevel) + logging.basicConfig(level=loglevel, + format='#[%(asctime)-15s][%(levelname)s]: %(message)s') + + logger = logging.getLogger(inst) + logger.setLevel(loglevel) + fh = logging.FileHandler(path.join(log_path, inst + '.log')) + fh.setLevel(loglevel) + logger.addHandler(fh) + logging.root.addHandler(fh) # ??? + return logger diff --git a/src/paths.py b/src/paths.py new file mode 100644 index 0000000..4c4f3aa --- /dev/null +++ b/src/paths.py @@ -0,0 +1,33 @@ +# -*- 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 +# +# ***************************************************************************** + +"""Pathes. how to find what and where...""" + +import sys +from os import path + + +basepath = path.abspath(path.join(sys.path[0], '..')) +etc_path = path.join(basepath, 'etc') +pid_path = path.join(basepath, 'pid') +log_path = path.join(basepath, 'log') +sys.path[0] = path.join(basepath, 'src') diff --git a/src/protocol/__init__.py b/src/protocol/__init__.py index e69de29..a9803ba 100644 --- a/src/protocol/__init__.py +++ b/src/protocol/__init__.py @@ -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 +# +# ***************************************************************************** +"""SECoP protocl specific stuff""" diff --git a/src/protocol/device.py b/src/protocol/device.py index f324ed7..4884184 100644 --- a/src/protocol/device.py +++ b/src/protocol/device.py @@ -22,7 +22,9 @@ """Define SECoP Device classes -also define helpers to derive properties of the device""" +""" +# XXX: is this still needed ??? +# see devices.core .... from lib import attrdict from protocol import status @@ -34,27 +36,36 @@ class Device(object): all others derive from this""" name = None + def read_status(self): raise NotImplementedError('All Devices need a Status!') + def read_name(self): return self.name + class Readable(Device): """A Readable Device""" unit = '' + def read_value(self): raise NotImplementedError('A Readable MUST provide a value') + def read_unit(self): return self.unit + class Writeable(Readable): """Writeable can be told to change it's vallue""" target = None + def read_target(self): return self.target + def write_target(self, target): self.target = target + class Driveable(Writeable): """A Moveable which may take a while to reach its target, @@ -62,32 +73,3 @@ class Driveable(Writeable): def do_stop(self): raise NotImplementedError('A Driveable MUST implement the STOP() ' 'command') - - -def get_device_pars(dev): - """return a mapping of the devices parameter names to some - 'description'""" - res = {} - for n in dir(dev): - if n.startswith('read_'): - pname = n[5:] - entry = attrdict(readonly=True, description=getattr(dev, n).__doc__) - if hasattr(dev, 'write_%s' % pname): - entry['readonly'] = False - res[pname] = entry - return res - -def get_device_cmds(dev): - """return a mapping of the devices command names to some - 'description'""" - res = {} - for n in dir(dev): - if n.startswith('do_'): - cname = n[5:] - func = getattr(dev, n) - # XXX: use inspect! - entry = attrdict(description=func.__doc__, args='unknown') - res[cname] = entry - return res - - diff --git a/src/protocol/dispatcher.py b/src/protocol/dispatcher.py new file mode 100644 index 0000000..feccd17 --- /dev/null +++ b/src/protocol/dispatcher.py @@ -0,0 +1,270 @@ +# -*- 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 +# +# ***************************************************************************** + +"""Dispatcher for SECoP Messages + +Interface to the service offering part: + + - 'handle_request(connectionobj, data)' handles incoming request + will call 'queue_request(data)' on connectionobj before returning + - 'add_connection(connectionobj)' registers new connection + - 'remove_connection(connectionobj)' removes now longer functional connection + - may at any time call 'queue_async_request(connobj, data)' on the connobj + +Interface to the devices: + - add_device(devname, devobj, export=True) registers a new device under the + given name, may also register it for exporting (making accessible) + - get_device(devname) returns the requested device or None + - remove_device(devname_or_obj): removes the device (during shutdown) + +internal stuff which may be called + - get_devices(): return a list of devices + descriptive data as dict + - get_device_params(): + return a list of paramnames for this device + descriptive data +""" + +import threading + +from messages import * + + +class Dispatcher(object): + def __init__(self, logger, options): + self.log = logger + # XXX: move framing and encoding to interface! + self.framing = options.pop('framing') + self.encoding = options.pop('encoding') + # map ALL devname -> devobj + self._dispatcher_devices = {} + # list of EXPORTED devices + self._dispatcher_export = [] + # list all connections + self._dispatcher_connections = [] + # map eventname -> list of subscribed connections + self._dispatcher_subscriptions = {} + self._dispatcher_lock = threading.RLock() + + def handle_request(self, conn, data): + """handles incoming request + + will call 'queue.request(data)' on conn to send reply before returning + """ + self.log.debug('Dispatcher: handling data: %r' % data) + # play thread safe ! + with self._dispatcher_lock: + # de-frame data + frames = self.framing.decode(data) + self.log.debug('Dispatcher: frames=%r' % frames) + for frame in frames: + reply = None + # decode frame + msg = self.encoding.decode(frame) + self.log.debug('Dispatcher: msg=%r' % msg) + # act upon requestobj + msgtype = msg.TYPE + msgname = msg.NAME + msgargs = msg + # generate reply (coded and framed) + if msgtype != 'request': + reply = ProtocolErrorReply(msg) + else: + self.log.debug('Looking for handle_%s' % msgname) + handler = getattr(self, 'handle_%s' % msgname, None) + if handler: + reply = handler(msgargs) + else: + self.log.debug('Can not handle msg %r' % msg) + reply = self.unhandled(msgname, msgargs) + if reply: + conn.queue_reply(self._format_reply(reply)) + # queue reply viy conn.queue_reply(data) + + def _format_reply(self, reply): + msg = self.encoding.encode(reply) + frame = self.framing.encode(msg) + return frame + + def announce_update(self, device, pname, value): + """called by devices param setters to notify subscribers of new values + """ + eventname = '%s/%s' % (self.get_device(device).name, pname) + subscriber = self._dispatcher_subscriptions.get(eventname, None) + if subscriber: + reply = AsyncDataUnit(device=self.get_device(device).name, + param=pname, + value=str(value), + timestamp=time.time(), + ) + data = self._format_reply(reply) + for conn in subscriber: + conn.queue_async_reply(data) + + def subscribe(self, conn, device, pname): + eventname = '%s/%s' % (self.get_device(device).name, pname) + self._dispatcher_subscriptions.getdefault(eventname, set()).add(conn) + + def add_connection(self, conn): + """registers new connection""" + self._dispatcher_connections.append(conn) + + def remove_connection(self, conn): + """removes now longer functional connection""" + if conn in self._dispatcher_connections: + self._dispatcher_connections.remove(conn) + # XXX: also clean _dispatcher_subscriptions ! + + def register_device(self, devobj, devname, export=True): + self._dispatcher_devices[devname] = devobj + if export: + self._dispatcher_export.append(devname) + + def get_device(self, devname): + dev = self._dispatcher_devices.get(devname, None) + self.log.debug('get_device(%r) -> %r' % (devname, dev)) + return dev + + def remove_device(self, devname_or_obj): + devobj = self.get_device(devname_or_obj) or devname_or_obj + devname = devobj.name + if devname in self._dispatcher_export: + self._dispatcher_export.remove(devname) + self._dispatcher_devices.pop(devname) + # XXX: also clean _dispatcher_subscriptions + + def list_device_names(self): + # return a copy of our list + return self._dispatcher_export[:] + + def list_devices(self): + dn = [] + dd = {} + for devname in self._dispatcher_export: + dn.append(devname) + dev = self.get_device(devname) + descriptive_data = { + 'class': dev.__class__, + #'bases': dev.__bases__, + 'parameters': dev.PARAMS.keys(), + 'commands': dev.CMDS.keys(), + # XXX: what else? + } + dd[devname] = descriptive_data + return dn, dd + + def list_device_params(self, devname): + if devname in self._dispatcher_export: + # XXX: omit export=False params! + return self.get_device(devname).PARAMS + return {} + + # now the (defined) handlers for the different requests + def handle_Help(self, msg): + return HelpReply() + + def handle_ListDevices(self, msgargs): + # XXX: choose! + #return ListDevicesReply(self.list_device_names()) + return ListDevicesReply(*self.list_devices()) + + def handle_ListDeviceParams(self, msgargs): + devobj = self.get_device(msgargs.device) + if devobj: + return ListDeviceParamsReply(msgargs.device, + self.get_device_params(devobj)) + else: + return NoSuchDeviceErrorReply(msgargs.device) + + def handle_ReadValue(self, msgargs): + devobj = self.get_device(msgargs.device) + if devobj: + return ReadValueReply(msgargs.device, devobj.read_value(), + timestamp=time.time()) + else: + return NoSuchDeviceErrorReply(msgargs.device) + + def handle_ReadParam(self, msgargs): + devobj = self.get_device(msgargs.device) + if devobj: + readfunc = getattr(devobj, 'read_%s' % msgargs.param, None) + if readfunc: + return ReadParamReply(msgargs.device, msgargs.param, + readfunc(), timestamp=time.time()) + else: + return NoSuchParamErrorReply(msgargs.device, msgargs.param) + else: + return NoSuchDeviceErrorReply(msgargs.device) + + def handle_WriteParam(self, msgargs): + devobj = self.get_device(msgargs.device) + if devobj: + writefunc = getattr(devobj, 'write_%s' % msgargs.param, None) + if writefunc: + readbackvalue = writefunc(msgargs.value) or msgargs.value + # trigger async updates + setattr(devobj, msgargs.param, readbackvalue) + return WriteParamReply(msgargs.device, msgargs.param, + readbackvalue, + timestamp=time.time()) + else: + if getattr(devobj, 'read_%s' % msgargs.param, None): + return ParamReadonlyErrorReply(msgargs.device, + msgargs.param) + else: + return NoSuchParamErrorReply(msgargs.device, + msgargs.param) + else: + return NoSuchDeviceErrorReply(msgargs.device) + + def handle_RequestAsyncData(self, msgargs): + return ErrorReply('AsyncData is not (yet) supported') + + def handle_ListOfFeatures(self, msgargs): + # no features supported (yet) + return ListOfFeaturesReply([]) + + def handle_ActivateFeature(self, msgargs): + return ErrorReply('Features are not (yet) supported') + + def unhandled(self, msgname, msgargs): + """handler for unhandled Messages + + (no handle_ method was defined) + """ + self.log.error('IGN: got unhandled request %s' % msgname) + return ErrorReply('Got Unhandled Request') + + def parse_message(self, message): + # parses a message and returns + # msgtype, msgname and parameters of message (as dict) + msgtype = 'unknown' + msgname = 'unknown' + if isinstance(message, ErrorReply): + msgtype = message.TYPE + msgname = message.__class__.__name__[:-len('Reply')] + elif isinstance(message, Request): + msgtype = message.TYPE + msgname = message.__class__.__name__[:-len('Request')] + elif isinstance(message, Reply): + msgtype = message.TYPE + msgname = message.__class__.__name__[:-len('Reply')] + return msgtype, msgname, \ + attrdict([(k, getattr(message, k)) for k in message.ARGS]) diff --git a/src/protocol/interface/__init__.py b/src/protocol/interface/__init__.py new file mode 100644 index 0000000..828da54 --- /dev/null +++ b/src/protocol/interface/__init__.py @@ -0,0 +1,31 @@ +# -*- 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 +# +# ***************************************************************************** +"""provide server interfaces to be used by clients""" + +from tcp import TCPServer + +INTERFACES = { + 'tcp': TCPServer, +} + +# for 'from protocol.interface import *' to only import the dict +__ALL__ = ['INTERFACES'] diff --git a/src/protocol/interface/tcp.py b/src/protocol/interface/tcp.py new file mode 100644 index 0000000..c6393af --- /dev/null +++ b/src/protocol/interface/tcp.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# ***************************************************************************** +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Enrico Faulhaber +# +# ***************************************************************************** + +"""provides tcp interface to the SECoP Server""" + +import os +import socket +import collections +import SocketServer + +DEF_PORT = 10767 +MAX_MESSAGE_SIZE = 1024 + + +class TCPRequestHandler(SocketServer.BaseRequestHandler): + def setup(self): + self.log = self.server.log + self._queue = collections.deque(maxlen=100) + + def handle(self): + """handle a new tcp-connection""" + # copy state info + mysocket = self.request + clientaddr = self.client_address + serverobj = self.server + self.log.debug("handling new connection from %s" % repr(clientaddr)) + # notify dispatcher of us + serverobj.dispatcher.add_connection(self) + + mysocket.settimeout(.3) + mysocket.setblocking(False) + # start serving + while True: + # send replys fist, then listen for requests, timing out after 0.1s + while self._queue: + mysocket.sendall(self._queue.popleft()) + # XXX: improve: use polling/select here? + try: + data = mysocket.recv(MAX_MESSAGE_SIZE) + except (socket.timeout, socket.error) as e: + continue + # XXX: should use select instead of busy polling + if not data: + continue + # dispatcher will queue the reply before returning + serverobj.dispatcher.handle_request(self, data) + + def queue_async_reply(self, data): + """called by dispatcher for async data units""" + self._queue.append(data) + + def queue_reply(self, data): + """called by dispatcher to queue (sync) replies""" + # sync replies go first! + self._queue.appendleft(data) + + def finish(self): + """called when handle() terminates, i.e. the socket closed""" + # notify dispatcher + self.server.dispatcher.remove_connection(self) + # close socket + try: + self.request.shutdown(socket.SHUT_RDWR) + finally: + self.request.close() + + +class TCPServer(SocketServer.ThreadingTCPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, logger, serveropts, dispatcher): + self.dispatcher = dispatcher + self.log = logger + bindto = serveropts.pop('bindto', 'localhost') + portnum = int(serveropts.pop('bindport', DEF_PORT)) + if ':' in bindto: + bindto, _port = bindto.rsplit(':') + portnum = int(_port) + self.log.debug("TCPServer binding to %s:%d" % (bindto, portnum)) + SocketServer.ThreadingTCPServer.__init__(self, (bindto, portnum), + TCPRequestHandler, + bind_and_activate=True) + self.log.info("TCPServer initiated") diff --git a/src/protocol/interface/zmq.py b/src/protocol/interface/zmq.py new file mode 100644 index 0000000..6020a5c --- /dev/null +++ b/src/protocol/interface/zmq.py @@ -0,0 +1,27 @@ +# -*- 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 +# +# ***************************************************************************** +"""provide a zmq server""" + +# tbd. + +# use zmq frames?? +# handle async and sync with different zmq ports? diff --git a/src/protocol/messages.py b/src/protocol/messages.py index 02fb4d5..8b148b8 100644 --- a/src/protocol/messages.py +++ b/src/protocol/messages.py @@ -22,132 +22,165 @@ """Define SECoP Messages""" -from lib import attrdict -import time -from device import get_device_pars, get_device_cmds - -class Request(object): - """Base class for all Requests""" - pars = [] - def __repr__(self): - pars = ', '.join('%s=%r' % (k, self.__dict__[k]) for k in self.pars) - s = '%s(%s)' % (self.__class__.__name__, pars) - return s - -class Reply(object): - """Base class for all Replies""" - pars = [] - def __repr__(self): - pars = ', '.join('%s=%r' % (k, self.__dict__[k]) for k in self.pars) - s = '%s(%s)' % (self.__class__.__name__, pars) - return s +REQUEST = 'request' +REPLY = 'reply' +ERROR = 'error' +# base classes +class Message(object): + ARGS = [] + + def __init__(self, *args, **kwds): + names = self.ARGS[:] + if len(args) > len(names): + raise TypeError('%s.__init__() takes only %d argument(s) (%d given)' % + (self.__class__, len(names), len(args))) + for arg in args: + self.__dict__[names.pop(0)] = arg + # now check keyworded args if any + for k, v in kwds.items(): + if k not in names: + if k in self.ARGS: + raise TypeError('__init__() got multiple values for ' + 'keyword argument %r' % k) + raise TypeError('__init__() got an unexpected keyword ' + 'argument %r' % k) + names.remove(k) + self.__dict__[k] = v + if names: + raise TypeError('__init__() takes at least %d arguments (%d given)' + % len(self.ARGS), len(args)+len(kwds)) + self.NAME = (self.__class__.__name__[:-len(self.TYPE)] or + self.__class__.__name__) + + +class Request(Message): + TYPE = REQUEST + + +class Reply(Message): + TYPE = REPLY + + +class ErrorReply(Message): + TYPE = ERROR + + +# actuall message objects class ListDevicesRequest(Request): pass + class ListDevicesReply(Reply): - pars = ['list_of_devices'] - def __init__(self, args): - self.list_of_devices = args + ARGS = ['list_of_devices', 'descriptive_data'] class ListDeviceParamsRequest(Request): - pars = ['device'] - def __init__(self, device): - self.device = device + ARGS = ['device'] + class ListDeviceParamsReply(Reply): - pars = ['device', 'params'] - def __init__(self, device, params): - self.device = device - self.params = params + ARGS = ['device', 'params'] + class ReadValueRequest(Request): - pars = ['device', 'maxage'] - def __init__(self, device, maxage=0): - self.device = device - self.maxage = maxage + ARGS = ['device', 'maxage'] + class ReadValueReply(Reply): - pars = ['device', 'value', 'timestamp', 'error', 'unit'] - def __init__(self, device, value, timestamp=0, error=0, unit=None): - self.device = device - self.value = value - self.timestamp = timestamp - self.error = error - self.unit = unit + ARGS = ['device', 'value', 'timestamp', 'error', 'unit'] class ReadParamRequest(Request): - pars = ['device', 'param', 'maxage'] - def __init__(self, device, param, maxage=0): - self.device = device - self.param = param - self.maxage = maxage + ARGS = ['device', 'param', 'maxage'] + class ReadParamReply(Reply): - pars = ['device', 'param', 'value', 'timestamp', 'error', 'unit'] - def __init__(self, device, param, value, timestamp=0, error=0, unit=None): - self.device = device - self.param = param - self.value = value - self.timestamp = timestamp - self.error = error - self.unit = unit + ARGS = ['device', 'param', 'value', 'timestamp', 'error', 'unit'] class WriteParamRequest(Request): - pars = ['device', 'param', 'value'] - def __init__(self, device, param, value): - self.device = device - self.param = param - self.value = value + ARGS = ['device', 'param', 'value'] + class WriteParamReply(Reply): - pars = ['device', 'param', 'readback_value', 'timestamp', 'error', 'unit'] - def __init__(self, device, param, readback_value, timestamp=0, error=0, - unit=None): - self.device = device - self.param = param - self.readback_value = readback_value - self.timestamp = timestamp - self.error = error - self.unit = unit + ARGS = ['device', 'param', 'readback_value', 'timestamp', 'error', 'unit'] class RequestAsyncDataRequest(Request): - pars = ['device', 'params'] - def __init__(self, device, *args): - self.device = device - self.params = args + ARGS = ['device', 'params'] + class RequestAsyncDataReply(Reply): - pars = ['device', 'paramvalue_list'] - def __init__(self, device, *args): - self.device = device - self.paramvalue_list = args + ARGS = ['device', 'paramvalue_list'] + class AsyncDataUnit(ReadParamReply): - pass + ARGS = ['device', 'param', 'value', 'timestamp', 'error', 'unit'] class ListOfFeaturesRequest(Request): pass + class ListOfFeaturesReply(Reply): - pars = ['features'] - def __init__(self, *args): - self.features = args + ARGS = ['features'] + class ActivateFeatureRequest(Request): - pars = ['feature'] - def __init__(self, feature): - self.feature = feature + ARGS = ['feature'] + class ActivateFeatureReply(Reply): # Ack style or Error - # may be should reply with active features? + # maybe should reply with active features? + pass + + +class ProtocollError(ErrorReply): + ARGS = ['msgtype', 'msgname', 'msgargs'] + + +class ErrorReply(Reply): + ARGS = ['error'] + + +class NoSuchDeviceErrorReply(ErrorReply): + ARGS = ['device'] + + +class NoSuchParamErrorReply(ErrorReply): + ARGS = ['device', 'param'] + + +class ParamReadonlyErrorReply(ErrorReply): + ARGS = ['device', 'param'] + + +class UnsupportedFeatureErrorReply(ErrorReply): + ARGS = ['feature'] + + +class NoSuchCommandErrorReply(ErrorReply): + ARGS = ['device', 'command'] + + +class CommandFailedErrorReply(ErrorReply): + ARGS = ['device', 'command'] + + +class InvalidParamValueErrorReply(ErrorReply): + ARGS = ['device', 'param', 'value'] + +# Fun! + + +class HelpRequest(Request): + pass + + +class HelpReply(Reply): pass @@ -155,203 +188,5 @@ FEATURES = [ 'Feature1', 'Feature2', 'Feature3', + 'Future', ] - - -# Error replies: - -class ErrorReply(Reply): - pars = ['error'] - def __init__(self, error): - self.error = error - -class NoSuchDeviceErrorReply(ErrorReply): - pars = ['device'] - def __init__(self, device): - self.device = device - -class NoSuchParamErrorReply(ErrorReply): - pars = ['device', 'param'] - def __init__(self, device, param): - self.device = device - self.param = param - -class ParamReadonlyErrorReply(ErrorReply): - pars = ['device', 'param'] - def __init__(self, device, param): - self.device = device - self.param = param - -class UnsupportedFeatureErrorReply(ErrorReply): - pars = ['feature'] - def __init__(self, feature): - self.feature = feature - -class NoSuchCommandErrorReply(ErrorReply): - pars = ['device', 'command'] - def __init__(self, device, command): - self.device = device - self.command = command - -class CommandFailedErrorReply(ErrorReply): - pars = ['device', 'command'] - def __init__(self, device, command): - self.device = device - self.command = command - -class InvalidParamValueErrorReply(ErrorReply): - pars = ['device', 'param', 'value'] - def __init__(self, device, param, value): - self.device = device - self.param = param - self.value = value - -class MessageHandler(object): - """puts meaning to the request objects""" - def handle_ListDevices(self, msgargs): - return ListDevicesReply(self.listDevices()) - - def handle_ListDeviceParams(self, msgargs): - devobj = self.getDevice(msgargs.device) - if devobj: - return ListDeviceParamsReply(msgargs.device, - get_device_pars(devobj)) - else: - return NoSuchDeviceErrorReply(msgargs.device) - - def handle_ReadValue(self, msgargs): - devobj = self.getDevice(msgargs.device) - if devobj: - return ReadValueReply(msgargs.device, devobj.read_value(), - timestamp=time.time()) - else: - return NoSuchDeviceErrorReply(msgargs.device) - - def handle_ReadParam(self, msgargs): - devobj = self.getDevice(msgargs.device) - if devobj: - readfunc = getattr(devobj, 'read_%s' % msgargs.param, None) - if readfunc: - return ReadParamReply(msgargs.device, msgargs.param, - readfunc(), timestamp=time.time()) - else: - return NoSuchParamErrorReply(msgargs.device, msgargs.param) - else: - return NoSuchDeviceErrorReply(msgargs.device) - - def handle_WriteParam(self, msgargs): - devobj = self.getDevice(msgargs.device) - if devobj: - writefunc = getattr(devobj, 'write_%s' % msgargs.param, None) - if writefunc: - readbackvalue = writefunc(msgargs.value) or msgargs.value - return WriteParamReply(msgargs.device, msgargs.param, - readbackvalue, - timestamp=time.time()) - else: - if getattr(devobj, 'read_%s' % msgargs.param, None): - return ParamReadonlyErrorReply(msgargs.device, - msgargs.param) - else: - return NoSuchParamErrorReply(msgargs.device, - msgargs.param) - else: - return NoSuchDeviceErrorReply(msgargs.device) - - def handle_RequestAsyncData(self, msgargs): - return ErrorReply('AsyncData is not (yet) supported') - - def handle_ListOfFeatures(self, msgargs): - # no features supported (yet) - return ListOfFeaturesReply([]) - - def handle_ActivateFeature(self, msgargs): - return ErrorReply('Features are not (yet) supported') - - def unhandled(self, msgname, msgargs): - """handler for unhandled Messages - - (no handle_ method was defined) - """ - self.log.error('IGN: got unhandled request %s' % msgname) - return ErrorReply('Got Unhandled Request') - - -def parse(message): - # parses a message and returns - # msgtype, msgname and parameters of message (as dict) - msgtype = 'unknown' - msgname = 'unknown' - if isinstance(message, ErrorReply): - msgtype = 'error' - msgname = message.__class__.__name__[:-len('Reply')] - elif isinstance(message, Request): - msgtype = 'request' - msgname = message.__class__.__name__[:-len('Request')] - elif isinstance(message, Reply): - msgtype = 'reply' - msgname = message.__class__.__name__[:-len('Reply')] - return msgtype, msgname, \ - attrdict([(k, getattr(message, k)) for k in message.pars]) - -__ALL__ = ['ErrorReply', - 'NoSuchDeviceErrorReply', 'NoSuchParamErrorReply' - 'ParamReadonlyErrorReply', 'UnsupportedFeatureErrorReply', - 'NoSuchCommandErrorReply', 'CommandFailedErrorReply', - 'InvalidParamValueErrorReply', - 'Reply', - 'ListDevicesReply', 'ListDeviceParamsReply', 'ReadValueReply', - 'ReadParamReply', 'WriteParamReply', 'RequestAsyncDataReply', - 'AsyncDataUnit', 'ListOfFeaturesReply', 'ActivateFeatureReply', - 'Request', - 'ListDevicesRequest', 'ListDeviceParamsRequest', 'ReadValueRequest', - 'ReadParamRequest', 'WriteParamRequest', 'RequestAsyncDataRequest', - 'ListOfFeaturesRequest', 'ActivateFeatureRequest', - 'parse', 'MessageHandler', -] - -if __name__ == '__main__': - print "minimal testing: transport" - testcases = dict( - error=[ErrorReply(), - NoSuchDeviceErrorReply('device3'), - NoSuchParamErrorReply('device2', 'param3'), - ParamReadonlyErrorReply('device1', 'param1'), - UnsupportedFeatureErrorReply('feature5'), - NoSuchCommandErrorReply('device1', 'fance_command'), - CommandFailedErrorReply('device1', 'stop'), - InvalidParamValueErrorReply('device1', 'param2', 'STRING_Value'), - ], - reply=[Reply(), - ListDevicesReply('device1', 'device2'), - ListDeviceParamsReply('device', ['param1', 'param2']), - ReadValueReply('device2', 3.1415), - ReadParamReply('device1', 'param2', 2.718), - WriteParamReply('device1', 'param2', 2.718), - RequestAsyncDataReply('device1', '?what to put here?'), - AsyncDataUnit('device1', 'param2', 2.718), - ListOfFeaturesReply('feature1', 'feature2'), - ActivateFeatureReply(), - ], - request=[Request(), - ListDevicesRequest(), - ListDeviceParamsRequest('device1'), - ReadValueRequest('device2'), - ReadParamRequest('device1', 'param2'), - WriteParamRequest('device1', 'param2', 2.718), - RequestAsyncDataRequest('device1', ['param1', 'param2']), - ListOfFeaturesRequest(), - ActivateFeatureRequest('feature1'), - ], - ) - for msgtype, msgs in testcases.items(): - print "___ testing %ss ___" % msgtype - for msg in msgs: - print msg.__class__.__name__, 'is', msgtype, - decoded = parse(msg) - if decoded[0] != msgtype: - print "\tFAIL, got %r but expected %r" %(decoded[0], msgtype) - else: - print "\tOk" - print - diff --git a/src/protocol/status.py b/src/protocol/status.py index 9bca559..e0bc323 100644 --- a/src/protocol/status.py +++ b/src/protocol/status.py @@ -28,4 +28,3 @@ WARN = 300 UNSTABLE = 350 ERROR = 400 UNKNOWN = -1 - diff --git a/src/protocol/transport.py b/src/protocol/transport.py deleted file mode 100644 index f9be5c9..0000000 --- a/src/protocol/transport.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# ***************************************************************************** -# This program is free software; you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation; either version 2 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program; if not, write to the Free Software Foundation, Inc., -# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Module authors: -# Enrico Faulhaber -# -# ***************************************************************************** - -"""provides transport layer of SECoP""" - -# currently implements pickling Python-objects over plain TCP -# WARNING: This is not (really) portable to other languages! - -import time -import socket -import SocketServer -try: - import cPickle as pickle -except ImportError: - import pickle - -from server import DeviceServer -from messages import ListOfFeaturesRequest - -DEF_PORT = 10767 -MAX_MESSAGE_SIZE = 1024 - -def decodeMessage(msg): - """transport layer message -> msg object""" - return pickle.loads(msg) - -def encodeMessage(msgobj): - """msg object -> transport layer message""" - return pickle.dumps(msgobj) - -def encodeMessageFrame(msg): - """add transport layer encapsulation/framing of messages""" - return '%s\n' % msg - -def decodeMessageFrame(frame): - """remove transport layer encapsulation/framing of messages""" - if '\n' in frame: - # WARNING: ignores everything after first '\n' - return frame.split('\n', 1)[0] - # invalid/incomplete frames return nothing here atm. - return None - - -class SECoPClient(object): - """connects to a SECoPServer and provides communication""" - _socket = None - def connect(self, server='localhost'): - if self._socket: - raise Exception('%r is already connected!' % self) - if ':' not in server: - server = '%s:%d' % (server, DEF_PORT) - host, port = server.split(':') - port = int(port) - self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._socket.connect((host, port)) - self._negotiateServerSettings() - - def close(self): - if not self._socket: - raise Exception('%r is not connected!' % self) - self._socket.shutdown(socket.SHUT_WR) - self._socket.shutdown(socket.SHUT_RDWR) - self._socket.close() - self._socket = None - - def _sendRequest(self, request): - if not self._socket: - raise Exception('%r is not connected!' % self) - self._socket.send(encodeMessageFrame(encodeMessage(request))) - - def _recvReply(self): - if not self._socket: - raise Exception('%r is not connected!' % self) - rawdata = '' - while True: - data = self._socket.recv(MAX_MESSAGE_SIZE) - if not data: - time.sleep(0.1) - # XXX: needs timeout mechanism! - continue - rawdata = rawdata + data - msg = decodeMessageFrame(rawdata) - if msg: - return decodeMessage(msg) - - def _negotiateServerSettings(self): - self._sendRequest(ListOfFeaturesRequest()) - print self._recvReply() - # XXX: fill with life! - - -class SECoPRequestHandler(SocketServer.BaseRequestHandler): - def handle(self): - """handle a new tcp-connection""" - # self.client_address - mysocket = self.request - frame = '' - # start serving - while True: - _frame = mysocket.recv(MAX_MESSAGE_SIZE) - if not _frame: - time.sleep(0.1) - continue - frame = frame + _frame - msg = decodeMessageFrame(frame) - if msg: - requestObj = decodeMessage(msg) - replyObj = self.handle_request(requestObj) - mysocket.send(encodeMessageFrame(encodeMessage(replyObj))) - frame = '' - - def handle_request(self, requestObj): - # XXX: handle connection/Server specific Requests - # pass other (Device) requests to the DeviceServer - return self.server.handle(requestObj) - - -class SECoPServer(SocketServer.ThreadingTCPServer, DeviceServer): - daemon_threads = False - def __init__(self, logger, serveropts): - bindto = serveropts.pop('bindto', 'localhost') - portnum = DEF_PORT - if ':' in bindto: - bindto, _port = bindto.rsplit(':') - portnum = int(_port) - logger.debug("binding to %s:%d" % (bindto, portnum)) - super(SECoPServer, self).__init__((bindto, portnum), - SECoPRequestHandler, bind_and_activate=True) - logger.info("SECoPServer initiated") - logger.debug('serveropts remaining: %r' % serveropts) diff --git a/src/protocol/transport/__init__.py b/src/protocol/transport/__init__.py new file mode 100644 index 0000000..c0d3f80 --- /dev/null +++ b/src/protocol/transport/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# ***************************************************************************** +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Enrico Faulhaber +# +# ***************************************************************************** + +"""provides transport layer encapsulation for SECoP""" + +from framing import FRAMERS +from encoding import ENCODERS diff --git a/src/protocol/transport/encoding.py b/src/protocol/transport/encoding.py new file mode 100644 index 0000000..ab28113 --- /dev/null +++ b/src/protocol/transport/encoding.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# ***************************************************************************** +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Enrico Faulhaber +# +# ***************************************************************************** + +"""Encoding/decoding Messages""" + +# implement as class as they may need some internal 'state' later on +# (think compressors) + +from protocol import messages + + +# Base classes +class MessageEncoder(object): + """en/decode a single Messageobject""" + + def encode(self, messageobj): + """encodes the given message object into a frame""" + raise NotImplemented + + def decode(self, frame): + """decodes the given frame to a message object""" + raise NotImplemented + +# now some Implementations +try: + import cPickle as pickle +except ImportError: + import pickle + +import protocol.messages + + +class PickleEncoder(MessageEncoder): + + def encode(self, messageobj): + """msg object -> transport layer message""" + return pickle.dumps(messageobj) + + def decode(self, encoded): + """transport layer message -> msg object""" + return pickle.loads(encoded) + + +class TextEncoder(MessageEncoder): + def __init__(self): + # build safe namespace + ns = dict() + for n in dir(messages): + if n.endswith(('Request', 'Reply')): + ns[n] = getattr(messages, n) + self.namespace = ns + + def encode(self, messageobj): + """msg object -> transport layer message""" + # fun for Humans + if isinstance(messageobj, messages.HelpReply): + return "Error: try one of the following requests:\n" + \ + '\n'.join(['%s(%s)' % (getattr(messages, m).__name__, + ', '.join(getattr(messages, m).ARGS)) + for m in dir(messages) + if m.endswith('Request')]) + res = [] + for k in messageobj.ARGS: + res.append('%s=%r' % (k, getattr(messageobj, k, None))) + result = '%s(%s)' % (messageobj.__class__.__name__, ', '.join(res)) + return result + + def decode(self, encoded): + """transport layer message -> msg object""" + # WARNING: highly unsafe! + # think message='import os\nos.unlink('\')\n' + try: + return eval(encoded, self.namespace, {}) + except SyntaxError: + return messages.HelpRequest() + + +ENCODERS = { + 'pickle': PickleEncoder, + 'text': TextEncoder, +} + + +__ALL__ = ['ENCODERS'] diff --git a/src/protocol/transport/framing.py b/src/protocol/transport/framing.py new file mode 100644 index 0000000..bfafc98 --- /dev/null +++ b/src/protocol/transport/framing.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# ***************************************************************************** +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Enrico Faulhaber +# +# ***************************************************************************** + +"""Encoding/decoding Frames""" + + +# Base class +class Framer(object): + """Frames and unframes an encoded message + + also transforms the encoded message to the 'wire-format' (and vise-versa) + + note: not all MessageEncoders can use all Framers, + but the intention is to have this for as many as possible. + """ + def encode(self, *frames): + """return the wire-data for the given messageframes""" + raise NotImplemented + + def decode(self, data): + """return a list of messageframes found in data""" + raise NotImplemented + + def reset(self): + """resets the de/encoding stage (clears internal information)""" + raise NotImplemented + + +# now some Implementations + +class EOLFramer(Framer): + """Text based message framer + + messages are delimited by '\r\n' + upon reception the end of a message is detected by '\r\n','\n' or '\n\r' + """ + data = b'' + + def encode(self, *frames): + """add transport layer encapsulation/framing of messages""" + return b'%s\r\n' % b'\r\n'.join(frames) + + def decode(self, data): + """remove transport layer encapsulation/framing of messages + + returns a list of messageframes which got decoded from data! + """ + self.data += data + res = [] + while b'\n' in self.data: + frame, self.data = self.data.split(b'\n', 1) + if frame.endswith('\r'): + frame = frame[:-1] + if self.data.startswith('\r'): + self.data = self.data[1:] + res.append(frame) + return res + + def reset(self): + self.data = b'' + + +class RLEFramer(Framer): + data = b'' + frames_to_go = 0 + + def encode(self, *frames): + """add transport layer encapsulation/framing of messages""" + # format is 'number of frames:[framelengt:frme]*N' + frdata = ['%d:%s' % (len(frame), frame) for frame in frames] + return b'%d:' + b''.join(frdata) + + def decode(self, data): + """remove transport layer encapsulation/framing of messages + + returns a list of messageframes which got decoded from data! + """ + self.data += data + res = [] + while self.data: + if frames_to_go == 0: + if ':' in self.data: + # scan for and decode 'number of frames' + frnum, self.data = self.data.split(':', 1) + try: + self.frames_to_go = int(frnum) + except ValueError: + # can not recover, complain! + raise FramingError('invalid start of message found!') + else: + # not enough data to decode number of frames, + # return what we have + return res + while self.frames_to_go: + # there are still some (partial) frames stuck inside self.data + frlen, self.data = self.data.split(':', 1) + if len(self.data) >= frlen: + res.append(self.data[:frlen]) + self.data = self.data[frlen:] + self.frames_to_go -= 1 + else: + # not enough data for this frame, return what we have + return res + + def reset(self): + self.data = b'' + self.frames_to_go = 0 + + +FRAMERS = { + 'eol': EOLFramer, + 'rle': RLEFramer, +} + +__ALL__ = ['FRAMERS'] diff --git a/src/server.py b/src/server.py deleted file mode 100644 index c89cce5..0000000 --- a/src/server.py +++ /dev/null @@ -1,150 +0,0 @@ -# -*- 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 basic SECoP DeviceServer""" - -import time - -from protocol.messages import parse, ListDevicesRequest, ListDeviceParamsRequest, \ - ReadParamRequest, ErrorReply, MessageHandler - - - -class DeviceServer(MessageHandler): - def __init__(self, logger, serveropts): - self._devices = {} - self.log = logger - # XXX: check serveropts and raise if problems exist - # mandatory serveropts: interface=tcpip, encoder=pickle, frame=eol - # XXX: remaining opts are checked by the corresponding interface server - - def serve_forever(self): - self.log.error("Serving not yet implemented!") - - def register_device(self, deviceobj, devicename): - # make the server export a deviceobj under a given name. - # all exportet properties are taken from the device - if devicename in self._devices: - self.log.error('IGN: Device %r already registered' % devicename) - else: - self._devices[devicename] = deviceobj - deviceobj.name = devicename - - def unregister_device(self, device_obj_or_name): - if not device_obj_or_name in self._devices: - self.log.error('IGN: Device %r not registered!' % - device_obj_or_name) - else: - del self._devices[device_obj_or_name] - # may need to do more - - def get_device(self, devname): - """returns the requested deviceObj or None""" - devobj = self._devices.get(devname, None) - return devobj - - def list_devices(self): - return list(self._devices.keys()) - - def handle(self, msg): - # server got a message, handle it - msgtype, msgname, msgargs = parse(msg) - if msgtype != 'request': - self.log.error('IGN: Server only handles request, but got %s/%s!' % - (msgtype, msgname)) - return - try: - self.log.info('handling message %s with %r' % (msgname, msgargs)) - handler = getattr(self, 'handle_%s' * msgname, None) - if handler is None: - handler = self.unhandled - res = handler(msgargs) - self.log.info('replying with %r' % res) - return res - except Exception as err: - res = ErrorReply('Exception:\n%r' % err) - self.log.info('replying with %r' % res) - return res - - -if __name__ == '__main__': - from devices.core import Driveable - from protocol import status - class TestDevice(Driveable): - name = 'Unset' - unit = 'Oinks' - def read_status(self): - return status.OK - def read_value(self): - """The devices main value""" - return 3.1415 - def read_testpar1(self): - return 2.718 - def read_fail(self): - raise KeyError() - def read_none(self): - pass - def read_NotImplemented(self): - raise NotImplementedError('funny errors should be transported') - def do_wait(self): - time.sleep(3) - def do_stop(self): - pass - def do_count(self): - print "counting:" - for d in range(10-1, -1, -1): - print '%d', - time.sleep(1) - print - def do_add_args(self, arg1, arg2): - return arg1 + arg2 - def do_return_stuff(self): - return [{'a':1}, (2, 3)] - - print "minimal testing: server" - srv = DeviceServer() - srv.register_device(TestDevice(), 'dev1') - srv.register_device(TestDevice(), 'dev2') - devices = parse(srv.handle(ListDevicesRequest()))[2]['list_of_devices'] - print 'Srv exports these devices:', devices - for dev in sorted(devices): - print '___ testing device %s ___' % dev - params = parse(srv.handle(ListDeviceParamsRequest(dev)))[2]['params'] - print '-has params: ', sorted(params.keys()) - for p in sorted(params.keys()): - pinfo = params[p] - if pinfo.readonly: - print ' - param %r is readonly' % p - if pinfo.description: - print ' - param %r\'s description is: %r' % (p, - pinfo.description) - else: - print ' - param %r has no description' % p - replytype, replyname, rv = parse(srv.handle(ReadParamRequest(dev, - p))) - if replytype == 'error': - print ' - reading param %r resulted in error/%s' % (p, - replyname) - else: - print ' - param %r current value is %r' % (p, rv.value) - print ' - param %r current unit is %r' % (p, rv.unit) - diff --git a/src/startup.py b/src/startup.py new file mode 100644 index 0000000..708a18d --- /dev/null +++ b/src/startup.py @@ -0,0 +1,167 @@ +# -*- 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 helpers""" +import os +import psutil +import ConfigParser + +# apt install python-daemon !!!do not use pip install daemon <- wrong version! +import daemon +from daemon import pidlockfile + +from lib import read_pidfile, write_pidfile, get_class +from protocol.dispatcher import Dispatcher +from protocol.interface import INTERFACES +from protocol.transport import ENCODERS, FRAMERS +from errors import ConfigError +from logger import get_logger + +__ALL__ = ['kill_server', 'start_server'] + + +def kill_server(pidfile): + """kill a server specified by a pidfile""" + pid = read_pidfile(pidfile) + if pid is None: + # already dead/not started yet + return + # get process for this pid + for proc in psutil.process_iter(): + if proc.pid == pid: + break + proc.terminate() + proc.wait(3) + proc.kill() + + +def start_server(srvname, base_path, loglevel, daemonize=False): + """start a server, part1 + + handle the daemonizing and logging stuff and call the second step + """ + pidfilename = os.path.join(base_path, 'pid', srvname + '.pid') + pidfile = pidlockfile.TimeoutPIDLockFile(pidfilename, 3) + if daemonize: + with daemon.DaemonContext( + #files_preserve=[logFileHandler.stream], + pidfile=pidfile, + ): + try: + #write_pidfile(pidfilename, os.getpid()) + startup(srvname, base_path, loglevel) + except Exception as e: + logging.exception(e) + else: + write_pidfile(pidfilename, os.getpid()) + startup(srvname, base_path, loglevel) # blocks! + + +# unexported stuff here + +def startup(srvname, base_path, loglevel): + """really start a server (part2) + + loads the config, initiate all objects, link them together + and finally start the interface server. + Never returns. (may raise) + """ + cfgfile = os.path.join(base_path, 'etc', srvname + '.cfg') + + logger = get_logger(srvname, loglevel=loglevel) + logger.debug('parsing %r' % cfgfile) + + parser = ConfigParser.SafeConfigParser() + if not parser.read([cfgfile]): + logger.error('Couldn\'t read cfg file !') + raise ConfigError('Couldn\'t read cfg file %r' % cfgfile) + + # iterate over all sections, checking for devices/server + deviceopts = [] + serveropts = {} + for section in parser.sections(): + if section == 'server': + # store for later + serveropts = dict(item for item in parser.items('server')) + if section.lower().startswith('device '): + # device section + # omit leading 'device ' string + devname = section[len('device '):] + devopts = dict(item for item in parser.items(section)) + if 'class' not in devopts: + logger.error('Device %s needs a class option!') + raise ConfigError('cfgfile %r: Device %s needs a class option!' + % (cfgfile, devname)) + # try to import the class, raise if this fails + devopts['class'] = get_class(devopts['class']) + # all went well so far + deviceopts.append([devname, devopts]) + + # there are several sections which resultin almost identical code: refactor + def init_object(name, cls, logger, options={}, *args): + logger.debug('Creating ' + name) + # cls.__init__ should pop all used args from options! + obj = cls(logger, options, *args) + if options: + raise ConfigError('%s: don\'t know how to handle option(s): %s' % ( + cls.__name__, + ', '.join(options.keys()))) + return obj + + # evaluate Server specific stuff + if not serveropts: + raise ConfigError('cfg file %r needs a \'server\' section!' % cfgfile) + + # eval serveropts + Framing = FRAMERS[serveropts.pop('framing')] + Encoding = ENCODERS[serveropts.pop('encoding')] + Interface = INTERFACES[serveropts.pop('interface')] + + dispatcher = init_object('Dispatcher', Dispatcher, logger, + dict(encoding=Encoding(), + framing=Framing())) + # split 'server' section to allow more than one interface + # also means to move encoding and framing to the interface, + # so that the dispatcher becomes agnostic + interface = init_object('Interface', Interface, logger, serveropts, + dispatcher) + + # check devices opts by creating them + devs = [] + for devname, devopts in deviceopts: + devclass = devopts.pop('class') + # create device + logger.debug('Creating Device %r' % devname) + devobj = devclass(logger, devopts, devname, dispatcher) + devs.append([devname, devobj]) + + # connect devices with dispatcher + for devname, devobj in devs: + logger.info('registering device %r' % devname) + dispatcher.register_device(devobj, devname) + # also call init on the devices + logger.debug('device.init()') + devobj.init() + + # handle requests until stop is requsted + logger.info('startup done, handling transport messages') + interface.serve_forever() diff --git a/src/validators.py b/src/validators.py index 7aed820..fae08b4 100644 --- a/src/validators.py +++ b/src/validators.py @@ -22,30 +22,77 @@ """Define validators.""" -# a Validator validates a given object and raises an ValueError if it doesn't fit +# a Validator returns a validated object or raises an ValueError # easy python validators: int(), float(), str() +# also validators should have a __repr__ returning a 'python' string +# which recreates them + +class Validator(object): + # list of tuples: (name, converter) + params = [] + valuetype = float + + def __init__(self, *args, **kwds): + plist = self.params[:] + if len(args) > len(plist): + raise ProgrammingError('%s takes %d parameters only (%d given)' % ( + self.__class__.__name__, + len(plist), len(args))) + for pval in args: + pname, pconv = plist.pop(0) + if pname in kwds: + raise ProgrammingError('%s: positional parameter %s als given ' + 'as keyword!' % ( + self.__class__.__name__, + pname)) + self.__dict__[pname] = pconv(pval) + for pname, pconv in plist: + if pname in kwds: + pval = kwds.pop(pname) + self.__dict__[pname] = pconv(pval) + else: + raise ProgrammingError('%s: param %s left unspecified!' % ( + self.__class__.__name__, + pname)) + + if kwds: + raise ProgrammingError('%s got unknown arguments: %s' % ( + self.__class__.__name__, + ', '.join(list(kwds.keys())))) + + def __repr__(self): + params = ['%s=%r' % (pn, self.__dict__[pn]) for pn in self.params] + return ('%s(%s)' % (self.__class__.__name__, ', '.join(params))) -class floatrange(object): - def __init__(self, lower, upper): - self.lower = float(lower) - self.upper = float(upper) def __call__(self, value): - value = float(value) - if not self.lower <= value <= self.upper: - raise ValueError('Floatrange: value %r must be within %f and %f' % (value, self.lower, self.upper)) - return value + return self.check(self.valuetype(value)) -def positive(obj): - if obj <= 0: +class floatrange(Validator): + params = [('lower', float), ('upper', float)] + + def check(self, value): + if self.lower <= value <= self.upper: + return value + raise ValueError('Floatrange: value %r must be within %f and %f' % + (value, self.lower, self.upper)) + + +class positive(Validator): + def check(self, value): + if value > 0: + return value raise ValueError('Value %r must be positive!' % obj) - return obj -def nonnegative(obj): - if obj < 0: - raise ValueError('Value %r must be zero or positive!' % obj) - return obj +class nonnegative(Validator): + def check(self, value): + if value >= 0: + return value + raise ValueError('Value %r must be positive!' % obj) + + +# more complicated validator may not be able to use validator base class class mapping(object): def __init__(self, *args, **kwds): self.mapping = {} @@ -67,5 +114,9 @@ class mapping(object): def __call__(self, obj): if obj in self.mapping: return obj - raise ValueError("%r should be one of %r" % (obj, list(self.mapping.keys()))) + raise ValueError("%r should be one of %r" % + (obj, list(self.mapping.keys()))) + def __repr__(self): + params = ['%s=%r' % (mname, mval) for mname, mval in self.mapping] + return ('%s(%s)' % (self.__class__.__name__, ', '.join(params)))