Merge "Implement a variant of the Demo protocol from Markus"
This commit is contained in:
commit
69b979cdd0
@ -40,12 +40,11 @@ for dirpath, dirnames, filenames in os.walk(DOC_SRC):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
for fn in filenames:
|
||||
full_name = path.join(dirpath, fn)
|
||||
sub_name = path.relpath(full_name, DOC_SRC)
|
||||
final_name = path.join(DOC_DST, sub_name)
|
||||
|
||||
|
||||
if not fn.endswith('md'):
|
||||
# just copy everything else
|
||||
with open(full_name, 'rb') as fi:
|
||||
|
@ -67,7 +67,7 @@ def main(argv=None):
|
||||
args = parseArgv(argv[1:])
|
||||
|
||||
loglevel = 'debug' if args.verbose else ('error' if args.quiet else 'info')
|
||||
loggers.initLogging('secop', loglevel, path.join(log_path))
|
||||
loggers.initLogging('secop', loglevel, log_path)
|
||||
|
||||
srv = Server(args.name, basepath)
|
||||
|
||||
|
15
doc/todo.md
15
doc/todo.md
@ -21,18 +21,18 @@
|
||||
|
||||
## A Server ##
|
||||
|
||||
* get daemonizing working
|
||||
* handle -d (nodaemon) and -D (default, daemonize) cmd line args
|
||||
* support Async data units
|
||||
* support feature publishing and selection
|
||||
* rewrite MessageHandler to be agnostic of server
|
||||
|
||||
* move encoding to interface
|
||||
* allow multiple interfaces per server
|
||||
* fix error handling an make it consistent
|
||||
|
||||
## Device framework ##
|
||||
|
||||
* unify PARAMS and CONFIG (if no default value is given,
|
||||
it needs to be specified in cfgfile, otherwise its optional)
|
||||
* supply properties for PARAMS to auto-generate async data units
|
||||
* self-polling support
|
||||
* generic devicethreads
|
||||
* proxydevice
|
||||
* make get_device uri-aware
|
||||
|
||||
|
||||
## Testsuite ##
|
||||
@ -45,7 +45,6 @@ it needs to be specified in cfgfile, otherwise its optional)
|
||||
|
||||
* mabe use sphinx to generate docu: a pdf can then be auto-generated....
|
||||
* transfer build docu into wiki via automated jobfile
|
||||
Problem: wiki does not understand .md or .html
|
||||
|
||||
|
||||
|
||||
|
39
etc/demo.cfg
Normal file
39
etc/demo.cfg
Normal file
@ -0,0 +1,39 @@
|
||||
[server]
|
||||
bindto=0.0.0.0
|
||||
bindport=10767
|
||||
interface = tcp
|
||||
framing=demo
|
||||
encoding=demo
|
||||
|
||||
[device heatswitch]
|
||||
class=devices.demo.Switch
|
||||
switch_on_time=3
|
||||
switch_off_time=5
|
||||
|
||||
[device mf]
|
||||
class=devices.demo.MagneticField
|
||||
heatswitch = heatswitch
|
||||
|
||||
[device ts]
|
||||
class=devices.demo.SampleTemp
|
||||
sensor = 'Q1329V7R3'
|
||||
ramp = 4
|
||||
target = 10
|
||||
default = 10
|
||||
|
||||
[device tc1]
|
||||
class=devices.demo.CoilTemp
|
||||
sensor="X34598T7"
|
||||
|
||||
[device tc2]
|
||||
class=devices.demo.CoilTemp
|
||||
sensor="X39284Q8'
|
||||
|
||||
[device label]
|
||||
class=devices.demo.Label
|
||||
system=Cryomagnet MX15
|
||||
subdev_mf=mf
|
||||
subdev_ts=ts
|
||||
|
||||
[device vt]
|
||||
class=devices.demo.ValidatorTest
|
@ -2,7 +2,7 @@
|
||||
markdown>=2.6
|
||||
# general stuff
|
||||
psutil
|
||||
python-daemon
|
||||
python-daemon >=2.0
|
||||
# for zmq
|
||||
#pyzmq>=13.1.0
|
||||
|
||||
|
@ -27,45 +27,66 @@
|
||||
# all others MUST derive from those, the 'interface'-class is still derived
|
||||
# from these base classes (how to do this?)
|
||||
|
||||
import time
|
||||
import types
|
||||
import inspect
|
||||
|
||||
from errors import ConfigError, ProgrammingError
|
||||
from protocol import status
|
||||
from validators import mapping
|
||||
|
||||
EVENT_ONLY_ON_CHANGED_VALUES = True
|
||||
|
||||
# storage for PARAMeter settings:
|
||||
# if readonly is False, the value can be changed (by code, or remte)
|
||||
# if readonly is False, the value can be changed (by code, or remote)
|
||||
# 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
|
||||
# during startup, value is initialized with the default value or
|
||||
# from the config file if specified there
|
||||
|
||||
|
||||
class PARAM(object):
|
||||
def __init__(self, description, validator=None, default=Ellipsis,
|
||||
unit=None, readonly=False, export=True):
|
||||
|
||||
def __init__(self, description, validator=float, default=Ellipsis,
|
||||
unit=None, readonly=True, export=True):
|
||||
if isinstance(description, PARAM):
|
||||
# make a copy of a PARAM object
|
||||
self.__dict__.update(description.__dict__)
|
||||
return
|
||||
self.description = description
|
||||
self.validator = validator
|
||||
self.default = default
|
||||
self.unit = unit
|
||||
self.readonly = readonly
|
||||
self.export = export
|
||||
# internal caching...
|
||||
self.currentvalue = default
|
||||
# internal caching: value and timestamp of last change...
|
||||
self.value = default
|
||||
self.timestamp = 0
|
||||
|
||||
def __repr__(self):
|
||||
return '%s(%s)' % (self.__class__.__name__, ', '.join(
|
||||
['%s=%r' % (k, v) for k, v in sorted(self.__dict__.items())]))
|
||||
|
||||
|
||||
# storage for CMDs settings (description + call signature...)
|
||||
class CMD(object):
|
||||
|
||||
def __init__(self, description, arguments, result):
|
||||
# descriptive text for humans
|
||||
self.description = description
|
||||
# list of validators for arguments
|
||||
self.argumenttype = arguments
|
||||
# validator for results
|
||||
self.arguments = arguments
|
||||
# validator for result
|
||||
self.resulttype = result
|
||||
|
||||
def __repr__(self):
|
||||
return '%s(%s)' % (self.__class__.__name__, ', '.join(
|
||||
['%s=%r' % (k, v) for k, v in sorted(self.__dict__.items())]))
|
||||
|
||||
|
||||
# Meta class
|
||||
# warning: MAGIC!
|
||||
class DeviceMeta(type):
|
||||
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
newtype = type.__new__(mcs, name, bases, attrs)
|
||||
if '__constructed__' in attrs:
|
||||
@ -81,22 +102,56 @@ class DeviceMeta(type):
|
||||
setattr(newtype, entry, newentry)
|
||||
|
||||
# check validity of PARAM entries
|
||||
for pname, info in newtype.PARAMS.items():
|
||||
if not isinstance(info, PARAM):
|
||||
for pname, pobj in newtype.PARAMS.items():
|
||||
# XXX: allow dicts for overriding certain aspects only.
|
||||
if not isinstance(pobj, PARAM):
|
||||
raise ProgrammingError('%r: device PARAM %r should be a '
|
||||
'PARAM object!' % (name, pname))
|
||||
#XXX: greate getters and setters, setters should send async updates
|
||||
# XXX: create getters for the units of params ??
|
||||
# wrap of reading/writing funcs
|
||||
rfunc = attrs.get('read_' + pname, None)
|
||||
|
||||
def getter():
|
||||
return self.PARAMS[pname].currentvalue
|
||||
def wrapped_rfunc(self, maxage=0, pname=pname, rfunc=rfunc):
|
||||
if rfunc:
|
||||
value = rfunc(self, maxage)
|
||||
setattr(self, pname, value)
|
||||
return value
|
||||
else:
|
||||
# return cached value
|
||||
return self.PARAMS[pname].value
|
||||
if rfunc:
|
||||
wrapped_rfunc.__doc__ = rfunc.__doc__
|
||||
setattr(newtype, 'read_' + pname, wrapped_rfunc)
|
||||
|
||||
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)
|
||||
if not pobj.readonly:
|
||||
wfunc = attrs.get('write_' + pname, None)
|
||||
|
||||
attrs[pname] = property(getter, setter)
|
||||
def wrapped_wfunc(self, value, pname=pname, wfunc=wfunc):
|
||||
self.log.debug("setter: set %s to %r" % (pname, value))
|
||||
if wfunc:
|
||||
value = wfunc(self, value) or value
|
||||
# XXX: use setattr or direct manipulation
|
||||
# of self.PARAMS[pname]?
|
||||
setattr(self, pname, value)
|
||||
return value
|
||||
if wfunc:
|
||||
wrapped_wfunc.__doc__ = wfunc.__doc__
|
||||
setattr(newtype, 'write_' + pname, wrapped_wfunc)
|
||||
|
||||
def getter(self, pname=pname):
|
||||
return self.PARAMS[pname].value
|
||||
|
||||
def setter(self, value, pname=pname):
|
||||
pobj = self.PARAMS[pname]
|
||||
value = pobj.validator(value) if pobj.validator else value
|
||||
pobj.timestamp = time.time()
|
||||
if not EVENT_ONLY_ON_CHANGED_VALUES or (value != pobj.value):
|
||||
pobj.value = value
|
||||
# also send notification
|
||||
self.log.debug('%s is now %r' % (pname, value))
|
||||
self.DISPATCHER.announce_update(self, pname, pobj)
|
||||
|
||||
setattr(newtype, pname, property(getter, setter))
|
||||
|
||||
# also collect/update information about CMD's
|
||||
setattr(newtype, 'CMDS', getattr(newtype, 'CMDS', {}))
|
||||
@ -114,6 +169,15 @@ class DeviceMeta(type):
|
||||
|
||||
|
||||
# Basic device class
|
||||
#
|
||||
# within devices, parameters should only be addressed as self.<pname>
|
||||
# i.e. self.value, self.target etc...
|
||||
# these are accesses to the cached version.
|
||||
# they can also be written to
|
||||
# (which auto-calls self.write_<pname> and generate an async update)
|
||||
# if you want to 'update from the hardware', call self.read_<pname>
|
||||
# the return value of this method will be used as the new cached value and
|
||||
# be returned.
|
||||
class Device(object):
|
||||
"""Basic Device, doesn't do much"""
|
||||
__metaclass__ = DeviceMeta
|
||||
@ -123,7 +187,7 @@ class Device(object):
|
||||
DISPATCHER = None
|
||||
|
||||
def __init__(self, logger, cfgdict, devname, dispatcher):
|
||||
# remember the server object (for the async callbacks)
|
||||
# remember the dispatcher object (for the async callbacks)
|
||||
self.DISPATCHER = dispatcher
|
||||
self.log = logger
|
||||
self.name = devname
|
||||
@ -137,13 +201,17 @@ class Device(object):
|
||||
# is not specified in cfgdict
|
||||
for k, v in self.PARAMS.items():
|
||||
if k not in cfgdict:
|
||||
if v.default is Ellipsis:
|
||||
if v.default is Ellipsis and k != 'value':
|
||||
# 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
|
||||
|
||||
# replace CLASS level PARAM objects with INSTANCE level ones
|
||||
self.PARAMS[k] = PARAM(self.PARAMS[k])
|
||||
|
||||
# now 'apply' config:
|
||||
# pass values through the validators and store as attributes
|
||||
for k, v in cfgdict.items():
|
||||
@ -156,7 +224,6 @@ class Device(object):
|
||||
except ValueError as e:
|
||||
raise ConfigError('Device %s: config parameter %r:\n%r'
|
||||
% (self.name, k, e))
|
||||
# XXX: with or without prefix?
|
||||
setattr(self, k, v)
|
||||
|
||||
def init(self):
|
||||
@ -172,15 +239,15 @@ class Readable(Device):
|
||||
PARAMS = {
|
||||
'value': PARAM('current value of the device', readonly=True, default=0.),
|
||||
'status': PARAM('current status of the device', default=status.OK,
|
||||
validator=mapping(**{'idle': status.OK,
|
||||
'BUSY': status.BUSY,
|
||||
'WARN': status.WARN,
|
||||
'UNSTABLE': status.UNSTABLE,
|
||||
'ERROR': status.ERROR,
|
||||
'UNKNOWN': status.UNKNOWN}),
|
||||
readonly=True),
|
||||
}
|
||||
|
||||
def read_value(self, maxage=0):
|
||||
raise NotImplementedError
|
||||
|
||||
def read_status(self):
|
||||
return status.OK
|
||||
|
||||
|
||||
class Driveable(Readable):
|
||||
"""Basic Driveable device
|
||||
@ -188,8 +255,6 @@ class Driveable(Readable):
|
||||
providing a settable 'target' parameter to those of a Readable
|
||||
"""
|
||||
PARAMS = {
|
||||
'target': PARAM('target value of the device', default=0.),
|
||||
'target': PARAM('target value of the device', default=0.,
|
||||
readonly=False),
|
||||
}
|
||||
|
||||
def write_target(self, value):
|
||||
raise NotImplementedError
|
||||
|
@ -146,7 +146,7 @@ class Cryostat(Driveable):
|
||||
def __heatLink(self, coolertemp, sampletemp):
|
||||
"""heatflow from sample to cooler. may be negative..."""
|
||||
flow = (sampletemp - coolertemp) * \
|
||||
((coolertemp + sampletemp) ** 2)/400.
|
||||
((coolertemp + sampletemp) ** 2) / 400.
|
||||
cp = clamp(self.__coolerCP(coolertemp) * self.__sampleCP(sampletemp),
|
||||
1, 10)
|
||||
return clamp(flow, -cp, cp)
|
||||
@ -156,7 +156,7 @@ class Cryostat(Driveable):
|
||||
12 * temp / ((temp - 12.)**2 + 10) + 0.5
|
||||
|
||||
def __sampleLeak(self, temp):
|
||||
return 0.02/temp
|
||||
return 0.02 / temp
|
||||
|
||||
def thread(self):
|
||||
self.sampletemp = self.config_T_start
|
||||
@ -304,8 +304,8 @@ class Cryostat(Driveable):
|
||||
# obtain min/max
|
||||
deviation = 0
|
||||
for _, T in window:
|
||||
if abs(T-self.target) > deviation:
|
||||
deviation = abs(T-self.target)
|
||||
if abs(T - self.target) > deviation:
|
||||
deviation = abs(T - self.target)
|
||||
if (len(window) < 3) or deviation > self.tolerance:
|
||||
self.status = status.BUSY, 'unstable'
|
||||
elif self.setpoint == self.target:
|
||||
|
265
src/devices/demo.py
Normal file
265
src/devices/demo.py
Normal file
@ -0,0 +1,265 @@
|
||||
#!/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 <enrico.faulhaber@frm2.tum.de>
|
||||
# *****************************************************************************
|
||||
|
||||
"""testing devices"""
|
||||
|
||||
import time
|
||||
import random
|
||||
import threading
|
||||
|
||||
from devices.core import Readable, Driveable, PARAM
|
||||
from validators import *
|
||||
from protocol import status
|
||||
|
||||
|
||||
class Switch(Driveable):
|
||||
"""switch it on or off....
|
||||
"""
|
||||
PARAMS = {
|
||||
'value': PARAM('current state (on or off)',
|
||||
validator=mapping(on=1, off=0), default=0),
|
||||
'target': PARAM('wanted state (on or off)',
|
||||
validator=mapping(on=1, off=0), default=0,
|
||||
readonly=False),
|
||||
'switch_on_time': PARAM('how long to wait after switching the switch on', validator=floatrange(0, 60), unit='s', default=10, export=False),
|
||||
'switch_off_time': PARAM('how long to wait after switching the switch off', validator=floatrange(0, 60), unit='s', default=10, export=False),
|
||||
}
|
||||
|
||||
def init(self):
|
||||
self._started = 0
|
||||
|
||||
def read_value(self, maxage=0):
|
||||
# could ask HW
|
||||
# we just return the value of the target here.
|
||||
self._update()
|
||||
return self.value
|
||||
|
||||
def read_target(self, maxage=0):
|
||||
# could ask HW
|
||||
return self.target
|
||||
|
||||
def write_target(self, value):
|
||||
# could tell HW
|
||||
pass
|
||||
# note: setting self.target to the new value is done after this....
|
||||
# note: we may also return the read-back value from the hw here
|
||||
|
||||
def read_status(self, maxage=0):
|
||||
self.log.info("read status")
|
||||
self._update()
|
||||
if self.target == self.value:
|
||||
return status.OK
|
||||
return status.BUSY
|
||||
|
||||
def _update(self):
|
||||
started = self.PARAMS['target'].timestamp
|
||||
if self.target > self.value:
|
||||
if time.time() > started + self.switch_on_time:
|
||||
self.log.debug('is switched ON')
|
||||
self.value = self.target
|
||||
elif self.target < self.value:
|
||||
if time.time() > started + self.switch_off_time:
|
||||
self.log.debug('is switched OFF')
|
||||
self.value = self.target
|
||||
|
||||
|
||||
class MagneticField(Driveable):
|
||||
"""a liquid magnet
|
||||
"""
|
||||
PARAMS = {
|
||||
'value': PARAM('current field in T', unit='T',
|
||||
validator=floatrange(-15, 15), default=0),
|
||||
'ramp': PARAM('moving speed in T/min', unit='T/min',
|
||||
validator=floatrange(0, 1), default=0.1, readonly=False),
|
||||
'mode': PARAM('what to do after changing field', default=0,
|
||||
validator=mapping(persistent=1, hold=0), readonly=False),
|
||||
'heatswitch': PARAM('heat switch device',
|
||||
validator=str, export=False),
|
||||
}
|
||||
|
||||
def init(self):
|
||||
self._state = 'idle'
|
||||
self._heatswitch = self.DISPATCHER.get_device(self.heatswitch)
|
||||
_thread = threading.Thread(target=self._thread)
|
||||
_thread.daemon = True
|
||||
_thread.start()
|
||||
|
||||
def read_value(self, maxage=0):
|
||||
return self.value
|
||||
|
||||
def write_target(self, value):
|
||||
# could tell HW
|
||||
return round(value, 2)
|
||||
# note: setting self.target to the new value is done after this....
|
||||
# note: we may also return the read-back value from the hw here
|
||||
|
||||
def read_status(self, maxage=0):
|
||||
return status.OK if self._state == 'idle' else status.BUSY
|
||||
|
||||
def _thread(self):
|
||||
loopdelay = 1
|
||||
while True:
|
||||
ts = time.time()
|
||||
if self._state == 'idle':
|
||||
if self.target != self.value:
|
||||
self.log.debug('got new target -> switching heater on')
|
||||
self._state = 'switch_on'
|
||||
self._heatswitch.write_target('on')
|
||||
if self._state == 'switch_on':
|
||||
# wait until switch is on
|
||||
if self._heatswitch.read_value() == 'on':
|
||||
self.log.debug(
|
||||
'heatswitch is on -> ramp to %.3f' %
|
||||
self.target)
|
||||
self._state = 'ramp'
|
||||
if self._state == 'ramp':
|
||||
if self.target == self.value:
|
||||
self.log.debug('at field! mode is %r' % self.mode)
|
||||
if self.mode:
|
||||
self.log.debug('at field -> switching heater off')
|
||||
self._state = 'switch_off'
|
||||
self._heatswitch.write_target('off')
|
||||
else:
|
||||
self.log.debug('at field -> hold')
|
||||
self._state = 'idle'
|
||||
self.status = self.read_status() # push async
|
||||
else:
|
||||
step = self.ramp * loopdelay / 60.
|
||||
step = max(min(self.target - self.value, step), -step)
|
||||
self.value += step
|
||||
if self._state == 'switch_off':
|
||||
# wait until switch is off
|
||||
if self._heatswitch.read_value() == 'off':
|
||||
self.log.debug('heatswitch is off at %.3f' % self.value)
|
||||
self._state = 'idle'
|
||||
self.read_status() # update async
|
||||
time.sleep(max(0.01, ts + loopdelay - time.time()))
|
||||
self.log.error(self, 'main thread exited unexpectedly!')
|
||||
|
||||
|
||||
class CoilTemp(Readable):
|
||||
"""a coil temperature
|
||||
"""
|
||||
PARAMS = {
|
||||
'value': PARAM('Coil temperatur in K', unit='K',
|
||||
validator=float, default=0),
|
||||
'sensor': PARAM("Sensor number or calibration id",
|
||||
validator=str, readonly=True),
|
||||
}
|
||||
|
||||
def read_value(self, maxage=0):
|
||||
return round(2.3 + random.random(), 3)
|
||||
|
||||
|
||||
class SampleTemp(Driveable):
|
||||
"""a sample temperature
|
||||
"""
|
||||
PARAMS = {
|
||||
'value': PARAM('Sample temperatur in K', unit='K',
|
||||
validator=float, default=10),
|
||||
'sensor': PARAM("Sensor number or calibration id",
|
||||
validator=str, readonly=True),
|
||||
'ramp': PARAM('moving speed in K/min',
|
||||
validator=floatrange(0, 100), unit='K/min', default=0.1, readonly=False),
|
||||
}
|
||||
|
||||
def init(self):
|
||||
_thread = threading.Thread(target=self._thread)
|
||||
_thread.daemon = True
|
||||
_thread.start()
|
||||
|
||||
def write_target(self, value):
|
||||
# could tell HW
|
||||
return round(value, 2)
|
||||
# note: setting self.target to the new value is done after this....
|
||||
# note: we may also return the read-back value from the hw here
|
||||
|
||||
def _thread(self):
|
||||
loopdelay = 1
|
||||
while True:
|
||||
ts = time.time()
|
||||
if self.value == self.target:
|
||||
if self.status != status.OK:
|
||||
self.status = status.OK
|
||||
else:
|
||||
self.status = status.BUSY
|
||||
step = self.ramp * loopdelay / 60.
|
||||
step = max(min(self.target - self.value, step), -step)
|
||||
self.value += step
|
||||
time.sleep(max(0.01, ts + loopdelay - time.time()))
|
||||
self.log.error(self, 'main thread exited unexpectedly!')
|
||||
|
||||
|
||||
class Label(Readable):
|
||||
"""
|
||||
|
||||
"""
|
||||
PARAMS = {
|
||||
'system': PARAM("Name of the magnet system",
|
||||
validator=str, export=False),
|
||||
'subdev_mf': PARAM("name of subdevice for magnet status",
|
||||
validator=str, export=False),
|
||||
'subdev_ts': PARAM("name of subdevice for sample temp",
|
||||
validator=str, export=False),
|
||||
'value': PARAM("Value of out label string",
|
||||
validator=str)
|
||||
}
|
||||
|
||||
def read_value(self, maxage=0):
|
||||
strings = [self.system]
|
||||
|
||||
dev_ts = self.DISPATCHER.get_device(self.subdev_ts)
|
||||
if dev_ts:
|
||||
strings.append('at %.3f %s' %
|
||||
(dev_ts.read_value(), dev_ts.PARAMS['value'].unit))
|
||||
else:
|
||||
strings.append('No connection to sample temp!')
|
||||
|
||||
dev_mf = self.DISPATCHER.get_device(self.subdev_mf)
|
||||
if dev_mf:
|
||||
mf_stat = dev_mf.read_status()
|
||||
mf_mode = dev_mf.mode
|
||||
mf_val = dev_mf.value
|
||||
mf_unit = dev_mf.PARAMS['value'].unit
|
||||
if mf_stat == status.OK:
|
||||
state = 'Persistent' if mf_mode else 'Non-persistent'
|
||||
else:
|
||||
state = 'ramping'
|
||||
strings.append('%s at %.1f %s' % (state, mf_val, mf_unit))
|
||||
else:
|
||||
strings.append('No connection to magnetic field!')
|
||||
|
||||
return '; '.join(strings)
|
||||
|
||||
|
||||
class ValidatorTest(Readable):
|
||||
"""
|
||||
"""
|
||||
PARAMS = {
|
||||
'oneof': PARAM('oneof', validator=oneof(int, 'X', 2.718), readonly=False, default=4.0),
|
||||
'mapping': PARAM('mapping', validator=mapping('boo', 'faar', z=9), readonly=False, default=1),
|
||||
'vector': PARAM('vector of int, float and str', validator=vector(int, float, str), readonly=False, default=(1, 2.3, 'a')),
|
||||
'array': PARAM('array: 2..3 time oneof(0,1)', validator=array(oneof(2, 3), oneof(0, 1)), readonly=False, default=[1, 0, 1]),
|
||||
'nonnegative': PARAM('nonnegative', validator=nonnegative(), readonly=False, default=0),
|
||||
'positive': PARAM('positive', validator=positive(), readonly=False, default=1),
|
||||
'intrange': PARAM('intrange', validator=intrange(2, 9), readonly=False, default=4),
|
||||
'floatrange': PARAM('floatrange', validator=floatrange(-1, 1), readonly=False, default=0,),
|
||||
}
|
@ -28,14 +28,16 @@ from validators import floatrange
|
||||
|
||||
from epics import PV
|
||||
|
||||
|
||||
class LN2(Readable):
|
||||
"""Just a readable.
|
||||
|
||||
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)
|
||||
return round(100 * random.random(), 1)
|
||||
|
||||
|
||||
class Heater(Driveable):
|
||||
@ -46,11 +48,11 @@ class Heater(Driveable):
|
||||
"""
|
||||
PARAMS = {
|
||||
'maxheaterpower': PARAM('maximum allowed heater power',
|
||||
validator=floatrange(0, 100), unit='W'),
|
||||
validator=floatrange(0, 100), unit='W'),
|
||||
}
|
||||
|
||||
def read_value(self, maxage=0):
|
||||
return round(100*random.random(), 1)
|
||||
return round(100 * random.random(), 1)
|
||||
|
||||
def write_target(self, target):
|
||||
pass
|
||||
@ -64,23 +66,22 @@ class Temp(Driveable):
|
||||
"""
|
||||
PARAMS = {
|
||||
'sensor': PARAM("Sensor number or calibration id",
|
||||
validator=str, readonly=True),
|
||||
validator=str, readonly=True),
|
||||
}
|
||||
|
||||
def read_value(self, maxage=0):
|
||||
return round(100*random.random(), 1)
|
||||
return round(100 * random.random(), 1)
|
||||
|
||||
def write_target(self, target):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class EPICS_PV(Driveable):
|
||||
"""pyepics test device."""
|
||||
|
||||
PARAMS = {
|
||||
'sensor': PARAM("Sensor number or calibration id",
|
||||
validator=str, readonly=True),
|
||||
validator=str, readonly=True),
|
||||
'max_rpm': PARAM("Maximum allowed rpm",
|
||||
validator=str, readonly=True),
|
||||
}
|
||||
|
@ -35,14 +35,13 @@ from logging import Logger, Formatter, Handler, DEBUG, INFO, WARNING, ERROR, \
|
||||
from . import colors
|
||||
|
||||
|
||||
|
||||
LOGFMT = '%(asctime)s : %(levelname)-7s : %(name)-15s: %(message)s'
|
||||
DATEFMT = '%H:%M:%S'
|
||||
DATESTAMP_FMT = '%Y-%m-%d'
|
||||
SECONDS_PER_DAY = 60 * 60 * 24
|
||||
|
||||
LOGLEVELS = {'debug': DEBUG, 'info': INFO, 'warning': WARNING, 'error': ERROR}
|
||||
INVLOGLEVELS = {value : key for key, value in LOGLEVELS.items()}
|
||||
INVLOGLEVELS = {value: key for key, value in LOGLEVELS.items()}
|
||||
|
||||
|
||||
log = None
|
||||
@ -58,7 +57,10 @@ def initLogging(rootname='secop', rootlevel='info', logdir='/tmp/log'):
|
||||
log.addHandler(ColoredConsoleHandler())
|
||||
|
||||
# logfile for fg and bg process
|
||||
log.addHandler(LogfileHandler(logdir, rootname))
|
||||
if logdir.startswith('/var/log'):
|
||||
log.addHandler(LogfileHandler(logdir, rootname))
|
||||
else:
|
||||
log.addHandler(LogfileHandler(logdir, ''))
|
||||
|
||||
|
||||
def getLogger(name, subdir=False):
|
||||
@ -73,7 +75,6 @@ class SecopLogger(Logger):
|
||||
Logger.__init__(self, *args, **kwargs)
|
||||
SecopLogger._storeLoggerNameLength(self)
|
||||
|
||||
|
||||
def getChild(self, suffix, ownDir=False):
|
||||
child = Logger.getChild(self, suffix)
|
||||
child.setLevel(self.getEffectiveLevel())
|
||||
@ -111,7 +112,6 @@ class SecopLogger(Logger):
|
||||
SecopLogger.maxLogNameLength = len(logObj.name)
|
||||
|
||||
|
||||
|
||||
class ConsoleFormatter(Formatter):
|
||||
"""
|
||||
A lightweight formatter for the interactive console, with optional
|
||||
|
@ -27,12 +27,12 @@
|
||||
_codes = {}
|
||||
|
||||
_attrs = {
|
||||
'reset': '39;49;00m',
|
||||
'bold': '01m',
|
||||
'faint': '02m',
|
||||
'standout': '03m',
|
||||
'reset': '39;49;00m',
|
||||
'bold': '01m',
|
||||
'faint': '02m',
|
||||
'standout': '03m',
|
||||
'underline': '04m',
|
||||
'blink': '05m',
|
||||
'blink': '05m',
|
||||
}
|
||||
|
||||
for _name, _value in _attrs.items():
|
||||
|
@ -70,6 +70,7 @@ class Driveable(Writeable):
|
||||
"""A Moveable which may take a while to reach its target,
|
||||
|
||||
hence stopping it may be desired"""
|
||||
|
||||
def do_stop(self):
|
||||
raise NotImplementedError('A Driveable MUST implement the STOP() '
|
||||
'command')
|
||||
|
@ -37,17 +37,19 @@ Interface to the devices:
|
||||
- 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():
|
||||
- list_devices(): return a list of devices + descriptive data as dict
|
||||
- list_device_params():
|
||||
return a list of paramnames for this device + descriptive data
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
|
||||
from messages import *
|
||||
|
||||
|
||||
class Dispatcher(object):
|
||||
|
||||
def __init__(self, logger, options):
|
||||
self.log = logger
|
||||
# XXX: move framing and encoding to interface!
|
||||
@ -73,7 +75,12 @@ class Dispatcher(object):
|
||||
with self._dispatcher_lock:
|
||||
# de-frame data
|
||||
frames = self.framing.decode(data)
|
||||
if frames is None:
|
||||
# not enough data (yet) -> return and come back with more
|
||||
return None
|
||||
self.log.debug('Dispatcher: frames=%r' % frames)
|
||||
if not frames:
|
||||
conn.queue_reply(self._format_reply(HelpReply()))
|
||||
for frame in frames:
|
||||
reply = None
|
||||
# decode frame
|
||||
@ -82,45 +89,53 @@ class Dispatcher(object):
|
||||
# act upon requestobj
|
||||
msgtype = msg.TYPE
|
||||
msgname = msg.NAME
|
||||
msgargs = msg
|
||||
# generate reply (coded and framed)
|
||||
if msgtype != 'request':
|
||||
reply = ProtocolErrorReply(msg)
|
||||
reply = ProtocolError(msg)
|
||||
else:
|
||||
self.log.debug('Looking for handle_%s' % msgname)
|
||||
handler = getattr(self, 'handle_%s' % msgname, None)
|
||||
if handler:
|
||||
reply = handler(msgargs)
|
||||
reply = handler(conn, msg)
|
||||
else:
|
||||
self.log.debug('Can not handle msg %r' % msg)
|
||||
reply = self.unhandled(msgname, msgargs)
|
||||
reply = self.unhandled(msgname, msg)
|
||||
if reply:
|
||||
conn.queue_reply(self._format_reply(reply))
|
||||
# queue reply viy conn.queue_reply(data)
|
||||
# queue reply via conn.queue_reply(data)
|
||||
|
||||
def _format_reply(self, reply):
|
||||
self.log.debug('formatting reply %r' % reply)
|
||||
msg = self.encoding.encode(reply)
|
||||
self.log.debug('encoded is %r' % msg)
|
||||
frame = self.framing.encode(msg)
|
||||
self.log.debug('frame is %r' % frame)
|
||||
return frame
|
||||
|
||||
def announce_update(self, device, pname, value):
|
||||
def announce_update(self, devobj, pname, pobj):
|
||||
"""called by devices param setters to notify subscribers of new values
|
||||
"""
|
||||
eventname = '%s/%s' % (self.get_device(device).name, pname)
|
||||
devname = devobj.name
|
||||
eventname = '%s/%s' % (devname, 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(),
|
||||
reply = AsyncDataUnit(devname=devname,
|
||||
pname=pname,
|
||||
value=str(pobj.value),
|
||||
timestamp=pobj.timestamp,
|
||||
)
|
||||
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 subscribe(self, conn, devname, pname):
|
||||
eventname = '%s/%s' % (devname, pname)
|
||||
self._dispatcher_subscriptions.setdefault(eventname, set()).add(conn)
|
||||
|
||||
def unsubscribe(self, conn, devname, pname):
|
||||
eventname = '%s/%s' % (devname, pname)
|
||||
if eventname in self._dispatcher_subscriptions:
|
||||
self._dispatcher_subscriptions.remove(conn)
|
||||
|
||||
def add_connection(self, conn):
|
||||
"""registers new connection"""
|
||||
@ -133,6 +148,8 @@ class Dispatcher(object):
|
||||
# XXX: also clean _dispatcher_subscriptions !
|
||||
|
||||
def register_device(self, devobj, devname, export=True):
|
||||
self.log.debug('registering Device %r as %s (export=%r)' %
|
||||
(devobj, devname, export))
|
||||
self._dispatcher_devices[devname] = devobj
|
||||
if export:
|
||||
self._dispatcher_export.append(devname)
|
||||
@ -171,100 +188,295 @@ class Dispatcher(object):
|
||||
return dn, dd
|
||||
|
||||
def list_device_params(self, devname):
|
||||
self.log.debug('list_device_params(%r)' % devname)
|
||||
if devname in self._dispatcher_export:
|
||||
# XXX: omit export=False params!
|
||||
return self.get_device(devname).PARAMS
|
||||
res = {}
|
||||
for paramname, param in self.get_device(devname).PARAMS.items():
|
||||
if param.export == True:
|
||||
res[paramname] = param
|
||||
self.log.debug('list params for device %s -> %r' %
|
||||
(devname, res))
|
||||
return res
|
||||
self.log.debug('-> device is not to be exported!')
|
||||
return {}
|
||||
|
||||
# demo stuff
|
||||
def _setDeviceValue(self, devobj, value):
|
||||
# set the device value. return readback value
|
||||
# if return == None -> Ellispis (readonly!)
|
||||
if self._getDeviceParam(devobj, 'target') != Ellipsis:
|
||||
return self._setDeviceParam(devobj, 'target', value)
|
||||
return Ellipsis
|
||||
|
||||
def _getDeviceValue(self, devobj):
|
||||
# get the device value
|
||||
# if return == None -> Ellipsis
|
||||
return self._getDeviceParam(devobj, 'value')
|
||||
|
||||
def _setDeviceParam(self, devobj, pname, value):
|
||||
# set the device param. return readback value
|
||||
# if return == None -> Ellipsis (readonly!)
|
||||
pobj = devobj.PARAMS.get(pname, Ellipsis)
|
||||
if pobj == Ellipsis:
|
||||
return pobj
|
||||
if pobj.readonly:
|
||||
return self._getDeviceParam(devobj, pname)
|
||||
writefunc = getattr(devobj, 'write_%s' % pname, None)
|
||||
validator = pobj.validator
|
||||
value = validator(value)
|
||||
|
||||
if writefunc:
|
||||
value = writefunc(value) or value
|
||||
else:
|
||||
setattr(devobj, pname, value)
|
||||
|
||||
return self._getDeviceParam(devobj, pname)
|
||||
|
||||
def _getDeviceParam(self, devobj, pname):
|
||||
# get the device value
|
||||
# if return == None -> Ellipsis
|
||||
readfunc = getattr(devobj, 'read_%s' % pname, None)
|
||||
if readfunc:
|
||||
# should also update the pobj (via the setter from the metaclass)
|
||||
readfunc()
|
||||
pobj = devobj.PARAMS.get(pname, None)
|
||||
if pobj:
|
||||
return (pobj.value, pobj.timestamp)
|
||||
return getattr(devobj, pname, Ellipsis)
|
||||
|
||||
def handle_Demo(self, conn, msg):
|
||||
novalue = msg.novalue
|
||||
devname = msg.devname
|
||||
paramname = msg.paramname
|
||||
propname = msg.propname
|
||||
assign = msg.assign
|
||||
|
||||
res = []
|
||||
if novalue in ('+', '-'):
|
||||
# XXX: handling of subscriptions: propname is ignored
|
||||
if devname is None:
|
||||
# list all subscriptions for this connection
|
||||
for evname, conns in self._dispatcher_subscriptions.items():
|
||||
if conn in conns:
|
||||
res.append('+%s:%s' % evname.split('/'))
|
||||
devices = self._dispatcher_export if devname == '*' else [devname]
|
||||
for devname in devices:
|
||||
devobj = self.get_device(devname)
|
||||
if devname != '*' and devobj is None:
|
||||
return NoSuchDeviceError(devname)
|
||||
if paramname is None:
|
||||
pnames = ['value', 'status']
|
||||
elif paramname == '*':
|
||||
pnames = devobj.PARAMS.keys()
|
||||
else:
|
||||
pnames = [paramname]
|
||||
for pname in pnames:
|
||||
pobj = devobj.PARAMS.get(pname, None)
|
||||
if pobj and not pobj.export:
|
||||
continue
|
||||
if paramname != '*' and pobj is None:
|
||||
return NoSuchParamError(devname, paramname)
|
||||
|
||||
if novalue == '+':
|
||||
# subscribe
|
||||
self.subscribe(conn, devname, pname)
|
||||
res.append('+%s:%s' % (devname, pname))
|
||||
elif novalue == '-':
|
||||
# unsubscribe
|
||||
self.unsubscribe(conn, devname, pname)
|
||||
res.append('-%s:%s' % (devname, pname))
|
||||
return DemoReply(res)
|
||||
|
||||
if devname is None:
|
||||
return Error('no devname given!')
|
||||
devices = self._dispatcher_export if devname == '*' else [devname]
|
||||
for devname in devices:
|
||||
devobj = self.get_device(devname)
|
||||
if devname != '*' and devobj is None:
|
||||
return NoSuchDeviceError(devname)
|
||||
if paramname is None:
|
||||
# Access Devices
|
||||
val = self._setDeviceValue(
|
||||
devobj, assign) if assign else self._getDeviceValue(devobj)
|
||||
if val == Ellipsis:
|
||||
if assign:
|
||||
return ParamReadonlyError(devname, 'target')
|
||||
return NoSuchDevice(devname)
|
||||
formatfunc = lambda x: '' if novalue else ('=%r;t=%r' % x)
|
||||
res.append(devname + formatfunc(val))
|
||||
|
||||
else:
|
||||
pnames = devobj.PARAMS.keys(
|
||||
) if paramname == '*' else [paramname]
|
||||
for pname in pnames:
|
||||
pobj = devobj.PARAMS.get(pname, None)
|
||||
if pobj and not pobj.export:
|
||||
continue
|
||||
if paramname != '*' and pobj is None:
|
||||
return NoSuchParamError(devname, paramname)
|
||||
if propname is None:
|
||||
# access params
|
||||
callfunc = lambda x, y: self._setDeviceParam(x, y, assign) \
|
||||
if assign else self._getDeviceParam(x, y)
|
||||
formatfunc = lambda x: '' if novalue else (
|
||||
'=%r;t=%r' % x)
|
||||
try:
|
||||
res.append(('%s:%s' % (devname, pname)) +
|
||||
formatfunc(callfunc(devobj, pname)))
|
||||
except TypeError as e:
|
||||
return InternalError(e)
|
||||
else:
|
||||
props = pobj.__dict__.keys(
|
||||
) if propname == '*' else [propname]
|
||||
for prop in props:
|
||||
# read props
|
||||
try:
|
||||
if novalue:
|
||||
res.append(
|
||||
'%s:%s:%s' %
|
||||
(devname, pname, prop))
|
||||
else:
|
||||
res.append(
|
||||
'%s:%s:%s=%r' %
|
||||
(devname, pname, prop, getattr(
|
||||
pobj, prop)))
|
||||
except TypeError as e:
|
||||
return InternalError(e)
|
||||
|
||||
# now clean responce a little
|
||||
res = [
|
||||
e.replace(
|
||||
'/v=',
|
||||
'=') for e in sorted(
|
||||
(e.replace(
|
||||
':value=',
|
||||
'/v=') for e in res))]
|
||||
return DemoReply(res)
|
||||
|
||||
# now the (defined) handlers for the different requests
|
||||
def handle_Help(self, msg):
|
||||
def handle_Help(self, conn, msg):
|
||||
return HelpReply()
|
||||
|
||||
def handle_ListDevices(self, msgargs):
|
||||
def handle_ListDevices(self, conn, msg):
|
||||
# XXX: What about the descriptive data????
|
||||
# XXX: choose!
|
||||
#return ListDevicesReply(self.list_device_names())
|
||||
return ListDevicesReply(*self.list_devices())
|
||||
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))
|
||||
def handle_ListDeviceParams(self, conn, msg):
|
||||
# reply with a list of the parameter names for a given device
|
||||
self.log.error('Keep: ListDeviceParams')
|
||||
if msg.device in self._dispatcher_export:
|
||||
params = self.list_device_params(msg.device)
|
||||
return ListDeviceParamsReply(msg.device, params.keys())
|
||||
else:
|
||||
return NoSuchDeviceErrorReply(msgargs.device)
|
||||
return NoSuchDeviceError(msg.device)
|
||||
|
||||
def handle_ReadValue(self, msgargs):
|
||||
devobj = self.get_device(msgargs.device)
|
||||
if devobj:
|
||||
return ReadValueReply(msgargs.device, devobj.read_value(),
|
||||
def handle_ReadAllDevices(self, conn, msg):
|
||||
# reply with a bunch of ReadValueReplies, reading ALL devices
|
||||
result = []
|
||||
for devname in sorted(self.list_device_names()):
|
||||
devobj = self.get_device(devname)
|
||||
value = self._getdeviceValue(devobj)
|
||||
if value is not Ellipsis:
|
||||
result.append(ReadValueReply(devname, value,
|
||||
timestamp=time.time()))
|
||||
return ReadAllDevicesReply(readValueReplies=result)
|
||||
|
||||
def handle_ReadValue(self, conn, msg):
|
||||
devname = msg.device
|
||||
devobj = self.get_device(devname)
|
||||
if devobj is None:
|
||||
return NoSuchDeviceError(devname)
|
||||
|
||||
value = self._getdeviceValue(devname)
|
||||
if value is not Ellipsis:
|
||||
return ReadValueReply(devname, 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)
|
||||
return InternalError('undefined device value')
|
||||
|
||||
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_WriteValue(self, conn, msg):
|
||||
value = msg.value
|
||||
devname = msg.device
|
||||
devobj = self.get_device(devname)
|
||||
if devobj is None:
|
||||
return NoSuchDeviceError(devname)
|
||||
|
||||
def handle_RequestAsyncData(self, msgargs):
|
||||
return ErrorReply('AsyncData is not (yet) supported')
|
||||
pobj = getattr(devobj.PARAMS, 'target', None)
|
||||
if pobj is None:
|
||||
return NoSuchParamError(devname, 'target')
|
||||
|
||||
def handle_ListOfFeatures(self, msgargs):
|
||||
if pobj.readonly:
|
||||
return ParamReadonlyError(devname, 'target')
|
||||
|
||||
validator = pobj.validator
|
||||
try:
|
||||
value = validator(value)
|
||||
except Exception as e:
|
||||
return InvalidParamValueError(devname, 'target', value, e)
|
||||
|
||||
value = self._setDeviceValue(devobj, value) or value
|
||||
WriteValueReply(devname, value, timestamp=time.time())
|
||||
|
||||
def handle_ReadParam(self, conn, msg):
|
||||
devname = msg.device
|
||||
pname = msg.param
|
||||
devobj = self.get_device(devname)
|
||||
if devobj is None:
|
||||
return NoSuchDeviceError(devname)
|
||||
|
||||
pobj = getattr(devobj.PARAMS, pname, None)
|
||||
if pobj is None:
|
||||
return NoSuchParamError(devname, pname)
|
||||
|
||||
value = self._getdeviceParam(devobj, pname)
|
||||
if value is not Ellipsis:
|
||||
return ReadParamReply(devname, pname, value,
|
||||
timestamp=time.time())
|
||||
|
||||
return InternalError('undefined device value')
|
||||
|
||||
def handle_WriteParam(self, conn, msg):
|
||||
value = msg.value
|
||||
pname = msg.param
|
||||
devname = msg.device
|
||||
devobj = self.get_device(devname)
|
||||
if devobj is None:
|
||||
return NoSuchDeviceError(devname)
|
||||
|
||||
pobj = getattr(devobj.PARAMS, pname, None)
|
||||
if pobj is None:
|
||||
return NoSuchParamError(devname, pname)
|
||||
|
||||
if pobj.readonly:
|
||||
return ParamReadonlyError(devname, pname)
|
||||
|
||||
validator = pobj.validator
|
||||
try:
|
||||
value = validator(value)
|
||||
except Exception as e:
|
||||
return InvalidParamValueError(devname, pname, value, e)
|
||||
|
||||
value = self._setDeviceParam(devobj, pname, value) or value
|
||||
WriteParamReply(devname, pname, value, timestamp=time.time())
|
||||
|
||||
# XXX: !!!
|
||||
def handle_RequestAsyncData(self, conn, msg):
|
||||
return Error('AsyncData is not (yet) supported')
|
||||
|
||||
def handle_ListOfFeatures(self, conn, msg):
|
||||
# no features supported (yet)
|
||||
return ListOfFeaturesReply([])
|
||||
|
||||
def handle_ActivateFeature(self, msgargs):
|
||||
return ErrorReply('Features are not (yet) supported')
|
||||
def handle_ActivateFeature(self, conn, msg):
|
||||
return Error('Features are not (yet) supported')
|
||||
|
||||
def unhandled(self, msgname, msgargs):
|
||||
def unhandled(self, msgname, conn, msg):
|
||||
"""handler for unhandled Messages
|
||||
|
||||
(no handle_<messagename> 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])
|
||||
return Error('Got Unhandled Request')
|
||||
|
@ -32,6 +32,7 @@ MAX_MESSAGE_SIZE = 1024
|
||||
|
||||
|
||||
class TCPRequestHandler(SocketServer.BaseRequestHandler):
|
||||
|
||||
def setup(self):
|
||||
self.log = self.server.log
|
||||
self._queue = collections.deque(maxlen=100)
|
||||
|
@ -48,12 +48,19 @@ class Message(object):
|
||||
'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))
|
||||
for name in names:
|
||||
self.__dict__[name] = None
|
||||
# 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__)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__class__.__name__ + '(' + \
|
||||
', '.join('%s=%r' % (k, getattr(self, k))
|
||||
for k in self.ARGS if getattr(self, k) is not None) + ')'
|
||||
|
||||
|
||||
class Request(Message):
|
||||
TYPE = REQUEST
|
||||
@ -66,6 +73,16 @@ class Reply(Message):
|
||||
class ErrorReply(Message):
|
||||
TYPE = ERROR
|
||||
|
||||
# for DEMO
|
||||
|
||||
|
||||
class DemoRequest(Request):
|
||||
ARGS = ['novalue', 'devname', 'paramname', 'propname', 'assign']
|
||||
|
||||
|
||||
class DemoReply(Reply):
|
||||
ARGS = ['lines']
|
||||
|
||||
|
||||
# actuall message objects
|
||||
class ListDevicesRequest(Request):
|
||||
@ -92,6 +109,30 @@ class ReadValueReply(Reply):
|
||||
ARGS = ['device', 'value', 'timestamp', 'error', 'unit']
|
||||
|
||||
|
||||
class WriteValueRequest(Request):
|
||||
ARGS = ['device', 'value', 'unit'] # unit???
|
||||
|
||||
|
||||
class WriteValueReply(Reply):
|
||||
ARGS = ['device', 'value', 'timestamp', 'error', 'unit']
|
||||
|
||||
|
||||
class ReadAllDevicesRequest(Request):
|
||||
ARGS = ['maxage']
|
||||
|
||||
|
||||
class ReadAllDevicesReply(Reply):
|
||||
ARGS = ['readValueReplies']
|
||||
|
||||
|
||||
class ListParamPropsRequest(Request):
|
||||
ARGS = ['device', 'param']
|
||||
|
||||
|
||||
class ListParamPropsReply(Request):
|
||||
ARGS = ['device', 'param', 'props']
|
||||
|
||||
|
||||
class ReadParamRequest(Request):
|
||||
ARGS = ['device', 'param', 'maxage']
|
||||
|
||||
@ -117,7 +158,7 @@ class RequestAsyncDataReply(Reply):
|
||||
|
||||
|
||||
class AsyncDataUnit(ReadParamReply):
|
||||
ARGS = ['device', 'param', 'value', 'timestamp', 'error', 'unit']
|
||||
ARGS = ['devname', 'pname', 'value', 'timestamp', 'error', 'unit']
|
||||
|
||||
|
||||
class ListOfFeaturesRequest(Request):
|
||||
@ -138,40 +179,47 @@ class ActivateFeatureReply(Reply):
|
||||
pass
|
||||
|
||||
|
||||
class ProtocollError(ErrorReply):
|
||||
ARGS = ['msgtype', 'msgname', 'msgargs']
|
||||
|
||||
# ERRORS
|
||||
########
|
||||
|
||||
class ErrorReply(Reply):
|
||||
ARGS = ['error']
|
||||
|
||||
|
||||
class NoSuchDeviceErrorReply(ErrorReply):
|
||||
class InternalError(ErrorReply):
|
||||
ARGS = ['error']
|
||||
|
||||
|
||||
class ProtocollError(ErrorReply):
|
||||
ARGS = ['error']
|
||||
|
||||
|
||||
class NoSuchDeviceError(ErrorReply):
|
||||
ARGS = ['device']
|
||||
|
||||
|
||||
class NoSuchParamErrorReply(ErrorReply):
|
||||
class NoSuchParamError(ErrorReply):
|
||||
ARGS = ['device', 'param']
|
||||
|
||||
|
||||
class ParamReadonlyErrorReply(ErrorReply):
|
||||
class ParamReadonlyError(ErrorReply):
|
||||
ARGS = ['device', 'param']
|
||||
|
||||
|
||||
class UnsupportedFeatureErrorReply(ErrorReply):
|
||||
class UnsupportedFeatureError(ErrorReply):
|
||||
ARGS = ['feature']
|
||||
|
||||
|
||||
class NoSuchCommandErrorReply(ErrorReply):
|
||||
class NoSuchCommandError(ErrorReply):
|
||||
ARGS = ['device', 'command']
|
||||
|
||||
|
||||
class CommandFailedErrorReply(ErrorReply):
|
||||
class CommandFailedError(ErrorReply):
|
||||
ARGS = ['device', 'command']
|
||||
|
||||
|
||||
class InvalidParamValueErrorReply(ErrorReply):
|
||||
ARGS = ['device', 'param', 'value']
|
||||
class InvalidParamValueError(ErrorReply):
|
||||
ARGS = ['device', 'param', 'value', 'error']
|
||||
|
||||
# Fun!
|
||||
|
||||
|
@ -28,3 +28,10 @@ WARN = 300
|
||||
UNSTABLE = 350
|
||||
ERROR = 400
|
||||
UNKNOWN = -1
|
||||
|
||||
#OK = 'idle'
|
||||
#BUSY = 'busy'
|
||||
#WARN = 'alarm'
|
||||
#UNSTABLE = 'unstable'
|
||||
#ERROR = 'ERROR'
|
||||
#UNKNOWN = 'unknown'
|
||||
|
@ -61,6 +61,7 @@ class PickleEncoder(MessageEncoder):
|
||||
|
||||
|
||||
class TextEncoder(MessageEncoder):
|
||||
|
||||
def __init__(self):
|
||||
# build safe namespace
|
||||
ns = dict()
|
||||
@ -94,9 +95,109 @@ class TextEncoder(MessageEncoder):
|
||||
return messages.HelpRequest()
|
||||
|
||||
|
||||
def format_time(ts):
|
||||
return float(ts) # XXX: switch to iso!
|
||||
|
||||
import re
|
||||
|
||||
DEMO_RE = re.compile(
|
||||
r'^([!+-])?(\*|[a-z_][a-z_0-9]*)?(?:\:(\*|[a-z_][a-z_0-9]*))?(?:\:(\*|[a-z_][a-z_0-9]*))?(?:\=(.*))?')
|
||||
|
||||
|
||||
def parse_str(s):
|
||||
# QnD Hack! try to parse lists/tuples/ints/floats, ignore dicts, specials
|
||||
# XXX: replace by proper parsing. use ast?
|
||||
s = s.strip()
|
||||
if s.startswith('[') and s.endswith(']'):
|
||||
# evaluate inner
|
||||
return [parse_str(part) for part in s[1:-1].split(',')]
|
||||
if s.startswith('(') and s.endswith(')'):
|
||||
# evaluate inner
|
||||
return [parse_str(part) for part in s[1:-1].split(',')]
|
||||
if s.startswith('"') and s.endswith('"'):
|
||||
# evaluate inner
|
||||
return s[1:-1]
|
||||
if s.startswith("'") and s.endswith("'"):
|
||||
# evaluate inner
|
||||
return s[1:-1]
|
||||
for conv in (int, float, lambda x: x):
|
||||
try:
|
||||
return conv(s)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
class DemoEncoder(MessageEncoder):
|
||||
|
||||
def decode(sef, encoded):
|
||||
# match [!][*|devicename][: *|paramname [: *|propname]] [=value]
|
||||
match = DEMO_RE.match(encoded)
|
||||
if match:
|
||||
novalue, devname, pname, propname, assign = match.groups()
|
||||
if assign:
|
||||
print "parsing", assign,
|
||||
assign = parse_str(assign)
|
||||
print "->", assign
|
||||
return messages.DemoRequest(
|
||||
novalue, devname, pname, propname, assign)
|
||||
return messages.HelpRequest()
|
||||
|
||||
def encode(self, msg):
|
||||
if isinstance(msg, messages.DemoReply):
|
||||
return msg.lines
|
||||
handler_name = '_encode_' + msg.__class__.__name__
|
||||
handler = getattr(self, handler_name, None)
|
||||
if handler is None:
|
||||
print "Handler %s not yet implemented!" % handler_name
|
||||
try:
|
||||
args = dict((k, msg.__dict__[k]) for k in msg.ARGS)
|
||||
result = handler(**args)
|
||||
except Exception as e:
|
||||
print "Error encoding %r with %r!" % (msg, handler)
|
||||
print e
|
||||
return '~InternalError~'
|
||||
return result
|
||||
|
||||
def _encode_AsyncDataUnit(self, devname, pname, value, timestamp,
|
||||
error=None, unit=''):
|
||||
return '#%s:%s=%s;t=%.3f' % (devname, pname, value, timestamp)
|
||||
|
||||
def _encode_Error(self, error):
|
||||
return '~Error~ %r' % error
|
||||
|
||||
def _encode_InternalError(self, error):
|
||||
return '~InternalError~ %r' % error
|
||||
|
||||
def _encode_ProtocollError(self, msgtype, msgname, msgargs):
|
||||
return '~ProtocolError~ %s.%s.%r' % (msgtype, msgname, msgargs)
|
||||
|
||||
def _encode_NoSuchDeviceError(self, device):
|
||||
return '~NoSuchDeviceError~ %s' % device
|
||||
|
||||
def _encode_NoSuchParamError(self, device, param):
|
||||
return '~NoSuchParameterError~ %s:%s' % (device, param)
|
||||
|
||||
def _encode_ParamReadonlyError(self, device, param):
|
||||
return '~ParamReadOnlyError~ %s:%s' % (device, param)
|
||||
|
||||
def _encode_NoSuchCommandError(self, device, command):
|
||||
return '~NoSuchCommandError~ %s.%s' % (device, command)
|
||||
|
||||
def _encode_CommandFailedError(self, device, command):
|
||||
return '~CommandFailedError~ %s.%s' % (device, command)
|
||||
|
||||
def _encode_InvalidParamValueError(self, device, param, value):
|
||||
return '~InvalidValueForParamError~ %s:%s=%r' % (device, param, value)
|
||||
|
||||
def _encode_HelpReply(self):
|
||||
return ['Help not yet implemented!',
|
||||
'ask Markus Zolliker about the protocol']
|
||||
|
||||
|
||||
ENCODERS = {
|
||||
'pickle': PickleEncoder,
|
||||
'text': TextEncoder,
|
||||
'demo': DemoEncoder,
|
||||
}
|
||||
|
||||
|
||||
|
@ -32,6 +32,7 @@ class Framer(object):
|
||||
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
|
||||
@ -126,9 +127,67 @@ class RLEFramer(Framer):
|
||||
self.frames_to_go = 0
|
||||
|
||||
|
||||
class DemoFramer(Framer):
|
||||
"""Text based message framer
|
||||
|
||||
frmes are delimited by '\n'
|
||||
messages are delimited by '\n\n'
|
||||
'\r' is ignored
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.data = b''
|
||||
self.decoded = []
|
||||
|
||||
def encode(self, frames):
|
||||
"""add transport layer encapsulation/framing of messages"""
|
||||
if isinstance(frames, (tuple, list)):
|
||||
return b'\n'.join(frames) + b'\n\n'
|
||||
return b'%s\n\n' % 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 decode2(self, data):
|
||||
"""remove transport layer encapsulation/framing of messages
|
||||
|
||||
returns a _list_ of messageframes which got decoded from data!
|
||||
"""
|
||||
self.data += data.replace(b'\r', '')
|
||||
while b'\n' in self.data:
|
||||
frame, self.data = self.data.split(b'\n', 1)
|
||||
if frame:
|
||||
# not an empty line -> belongs to this set of messages
|
||||
self.decoded.append(frame)
|
||||
else:
|
||||
# empty line -> our set of messages is finished decoding
|
||||
res = self.decoded
|
||||
self.decoded = []
|
||||
return res
|
||||
return None
|
||||
|
||||
def reset(self):
|
||||
self.data = b''
|
||||
self.decoded = []
|
||||
|
||||
|
||||
FRAMERS = {
|
||||
'eol': EOLFramer,
|
||||
'rle': RLEFramer,
|
||||
'demo': DemoFramer,
|
||||
}
|
||||
|
||||
__ALL__ = ['FRAMERS']
|
||||
|
@ -38,6 +38,7 @@ from errors import ConfigError
|
||||
|
||||
|
||||
class Server(object):
|
||||
|
||||
def __init__(self, name, workdir, parentLogger=None):
|
||||
self._name = name
|
||||
self._workdir = workdir
|
||||
@ -115,14 +116,23 @@ class Server(object):
|
||||
devclass = devopts.pop('class')
|
||||
# create device
|
||||
self.log.debug('Creating Device %r' % devname)
|
||||
export = devopts.pop('export', '1')
|
||||
export = export.lower() in ('1', 'on', 'true', 'yes')
|
||||
if 'default' in devopts:
|
||||
devopts['value'] = devopts.pop('default')
|
||||
# strip '"
|
||||
for k, v in devopts.items():
|
||||
for d in ("'", '"'):
|
||||
if v.startswith(d) and v.endswith(d):
|
||||
devopts[k] = v[1:-1]
|
||||
devobj = devclass(self.log.getChild(devname), devopts, devname,
|
||||
self._dispatcher)
|
||||
devs.append([devname, devobj])
|
||||
devs.append([devname, devobj, export])
|
||||
|
||||
# connect devices with dispatcher
|
||||
for devname, devobj in devs:
|
||||
for devname, devobj, export in devs:
|
||||
self.log.info('registering device %r' % devname)
|
||||
self._dispatcher.register_device(devobj, devname)
|
||||
self._dispatcher.register_device(devobj, devname, export)
|
||||
# also call init on the devices
|
||||
devobj.init()
|
||||
|
||||
@ -152,5 +162,3 @@ class Server(object):
|
||||
cls.__name__,
|
||||
', '.join(options.keys())))
|
||||
return obj
|
||||
|
||||
|
||||
|
@ -27,6 +27,13 @@
|
||||
# also validators should have a __repr__ returning a 'python' string
|
||||
# which recreates them
|
||||
|
||||
# if a validator does a mapping, it normally maps to the external representation (used for print/log/protocol/...)
|
||||
# to get the internal representation (for the code), call method convert
|
||||
|
||||
class ProgrammingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Validator(object):
|
||||
# list of tuples: (name, converter)
|
||||
params = []
|
||||
@ -61,12 +68,17 @@ class Validator(object):
|
||||
', '.join(list(kwds.keys()))))
|
||||
|
||||
def __repr__(self):
|
||||
params = ['%s=%r' % (pn, self.__dict__[pn]) for pn in self.params]
|
||||
params = ['%s=%r' % (pn[0], self.__dict__[pn[0]])
|
||||
for pn in self.params]
|
||||
return ('%s(%s)' % (self.__class__.__name__, ', '.join(params)))
|
||||
|
||||
def __call__(self, value):
|
||||
return self.check(self.valuetype(value))
|
||||
|
||||
def convert(self, value):
|
||||
# transforms the 'internal' representation into the 'external'
|
||||
return self.valuetype(value)
|
||||
|
||||
|
||||
class floatrange(Validator):
|
||||
params = [('lower', float), ('upper', float)]
|
||||
@ -78,22 +90,110 @@ class floatrange(Validator):
|
||||
(value, self.lower, self.upper))
|
||||
|
||||
|
||||
class intrange(Validator):
|
||||
params = [('lower', int), ('upper', int)]
|
||||
valuetype = int
|
||||
|
||||
def check(self, value):
|
||||
if self.lower <= value <= self.upper:
|
||||
return value
|
||||
raise ValueError('Intrange: 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)
|
||||
raise ValueError('Value %r must be > 0!' % value)
|
||||
|
||||
|
||||
class nonnegative(Validator):
|
||||
|
||||
def check(self, value):
|
||||
if value >= 0:
|
||||
return value
|
||||
raise ValueError('Value %r must be positive!' % obj)
|
||||
raise ValueError('Value %r must be >= 0!' % value)
|
||||
|
||||
|
||||
class array(Validator):
|
||||
"""integral amount of data-elements which are described by the SAME validator
|
||||
|
||||
The size of the array can also be described by an validator
|
||||
"""
|
||||
valuetype = list
|
||||
params = [('size', lambda x: x),
|
||||
('datatype', lambda x: x)]
|
||||
|
||||
def check(self, values):
|
||||
requested_size = len(values)
|
||||
try:
|
||||
allowed_size = self.size(requested_size)
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
'illegal number of elements %d, need %r: (%s)' %
|
||||
(requested_size, self.size, e))
|
||||
if requested_size != allowed_size:
|
||||
raise ValueError(
|
||||
'need %d elements (got %d)' %
|
||||
(allowed_size, requested_size))
|
||||
# apply data-type validator to all elements and return
|
||||
res = []
|
||||
for idx, el in enumerate(values):
|
||||
try:
|
||||
res.append(self.datatype(el))
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
'Array Element %s (=%r) not conforming to %r: (%s)' %
|
||||
(idx, el, self.datatype, e))
|
||||
return res
|
||||
|
||||
|
||||
# more complicated validator may not be able to use validator base class
|
||||
class vector(object):
|
||||
"""fixed length, eache element has its own validator"""
|
||||
|
||||
def __init__(self, *args):
|
||||
self.validators = args
|
||||
self.argstr = ', '.join([repr(e) for e in args])
|
||||
|
||||
def __call__(self, args):
|
||||
if len(args) != len(self.validators):
|
||||
raise ValueError('Vector: need exactly %d elementes (got %d)' %
|
||||
len(self.validators), len(args))
|
||||
return [v(e) for v, e in zip(self.validators, args)]
|
||||
|
||||
def __repr__(self):
|
||||
return ('%s(%s)' % (self.__class__.__name__, self.argstr))
|
||||
|
||||
|
||||
class oneof(object):
|
||||
"""needs to comply with one of the given validators/values"""
|
||||
|
||||
def __init__(self, *args):
|
||||
self.oneof = args
|
||||
self.argstr = ', '.join([repr(e) for e in args])
|
||||
|
||||
def __call__(self, arg):
|
||||
for v in self.oneof:
|
||||
if callable(v):
|
||||
try:
|
||||
if (v == int) and (float(arg) != int(arg)):
|
||||
continue
|
||||
return v(arg)
|
||||
except ValueError:
|
||||
pass # try next validator
|
||||
elif v == arg:
|
||||
return v
|
||||
raise ValueError('Oneof: %r should be one of: %s' % (arg, self.argstr))
|
||||
|
||||
def __repr__(self):
|
||||
return ('%s(%s)' % (self.__class__.__name__, self.argstr))
|
||||
|
||||
|
||||
class mapping(object):
|
||||
|
||||
def __init__(self, *args, **kwds):
|
||||
self.mapping = {}
|
||||
# use given kwds directly
|
||||
@ -112,11 +212,21 @@ class mapping(object):
|
||||
self.revmapping[v] = k
|
||||
|
||||
def __call__(self, obj):
|
||||
try:
|
||||
obj = int(obj)
|
||||
except ValueError:
|
||||
pass
|
||||
if obj in self.mapping:
|
||||
return obj
|
||||
if obj in self.revmapping:
|
||||
return self.revmapping[obj]
|
||||
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]
|
||||
params = ['%s=%r' % (mname, mval)
|
||||
for mname, mval in self.mapping.items()]
|
||||
return ('%s(%s)' % (self.__class__.__name__, ', '.join(params)))
|
||||
|
||||
def convert(self, arg):
|
||||
return self.mapping.get(arg, arg)
|
||||
|
Loading…
x
Reference in New Issue
Block a user