From 3fcd72b18951629a8b4e0f9e7c4687b8545c40dc Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Thu, 4 May 2023 16:34:09 +0200 Subject: [PATCH 1/7] merge manually with mlz repo as of 2023-05-04 Change-Id: I5926617c454844927799e20a489db20d538db100 --- bin/frappy-cfg-editor | 9 +- frappy/gui/cfg_editor/config_file.py | 120 +++++++++++++++++++++++++- frappy/gui/cfg_editor/mainwindow.py | 5 +- frappy/gui/cfg_editor/node_display.py | 4 +- frappy/gui/cfg_editor/utils.py | 4 +- frappy/gui/cfg_editor/widgets.py | 4 +- frappy/gui/valuewidgets.py | 2 +- frappy/lib/__init__.py | 98 ++++++++++++--------- frappy/lib/asynconn.py | 8 +- frappy/protocol/interface/tcp.py | 53 +++++++++--- frappy/server.py | 3 + frappy_mlz/entangle.py | 28 ++++-- frappy_psi/historywriter.py | 2 +- test/test_lib.py | 19 ++-- 14 files changed, 278 insertions(+), 81 deletions(-) diff --git a/bin/frappy-cfg-editor b/bin/frappy-cfg-editor index 2d77aee..a2c58fa 100755 --- a/bin/frappy-cfg-editor +++ b/bin/frappy-cfg-editor @@ -29,6 +29,9 @@ from os import path # Add import path for inplace usage sys.path.insert(0, path.abspath(path.join(path.dirname(__file__), '..'))) +import logging +from mlzlog import ColoredConsoleHandler + from frappy.gui.qt import QApplication from frappy.gui.cfg_editor.mainwindow import MainWindow @@ -38,7 +41,11 @@ def main(argv=None): parser.add_argument('-f', '--file', help='Configuration file to open.') args = parser.parse_args() app = QApplication(argv) - window = MainWindow(args.file) + logger = logging.getLogger('gui') + console = ColoredConsoleHandler() + console.setLevel(logging.INFO) + logger.addHandler(console) + window = MainWindow(args.file, log=logger) window.show() return app.exec() diff --git a/frappy/gui/cfg_editor/config_file.py b/frappy/gui/cfg_editor/config_file.py index 9be17a5..d6a3937 100644 --- a/frappy/gui/cfg_editor/config_file.py +++ b/frappy/gui/cfg_editor/config_file.py @@ -24,6 +24,8 @@ import configparser from collections import OrderedDict from configparser import NoOptionError +from frappy.config import load_config +from frappy.datatypes import StringType, EnumType from frappy.gui.cfg_editor.tree_widget_item import TreeWidgetItem from frappy.gui.cfg_editor.utils import get_all_children_with_names, \ get_all_items, get_interface_class_from_name, get_module_class_from_name, \ @@ -41,7 +43,7 @@ SECTIONS = {NODE: 'description', MODULE: 'class'} -def write_config(file_name, tree_widget): +def write_legacy_config(file_name, tree_widget): itms = get_all_items(tree_widget) itm_lines = OrderedDict() value_str = '%s = %s' @@ -79,8 +81,7 @@ def write_config(file_name, tree_widget): with open(file_name, 'w', encoding='utf-8') as configfile: configfile.write('\n'.join(itm_lines.values())) - -def read_config(file_path): +def read_legacy_config(file_path): # TODO datatype of params and properties node = TreeWidgetItem(NODE) ifs = TreeWidgetItem(name='Interfaces') @@ -147,6 +148,119 @@ def read_config(file_path): node = get_comments(node, ifs, mods, file_path) return node, ifs, mods +def fmt_value(param, dt): + if isinstance(dt, StringType): + return repr(param) + if isinstance(dt, EnumType): + try: + return int(param) + except ValueError: + return repr(param) + return param + +def fmt_param(param, pnp): + props = pnp[param.name] + if isinstance(props, list): + dt = props[0].datatype + else: + dt = props.datatype + + if param.childCount() > 1 or (param.childCount() == 1 and param.child(0).name != 'value'): + values = [] + main_value = param.get_value() + if main_value: + values.append(fmt_value(main_value, dt)) + for i in range(param.childCount()): + prop = param.child(i) + propdt = props[1][prop.name].datatype + propv = fmt_value(prop.get_value(), propdt) + values.append(f'{prop.name} = {propv}') + values = f'Param({", ".join(values)})' + else: + values = fmt_value(param.get_value(), dt) + return f' {param.name} = {values},' + +def write_config(file_name, tree_widget): + """stopgap python config writing. assumes no comments are in the cfg.""" + itms = get_all_items(tree_widget) + for itm in itms: + if itm.kind == 'comment': + print('comments are broken right now. not writing a file. exiting...') + return + lines = [] + root = tree_widget.topLevelItem(0) + eq_id = root.name + description = root.get_value() + iface = root.child(0).child(0).name + + lines.append(f'Node(\'{eq_id}\',\n {repr(description)},\n \'{iface}\',') + for i in range(2, root.childCount()): + lines.append(fmt_param(root.child(i), {})) + lines.append(')') + mods = root.child(1) + for i in range(mods.childCount()): + lines.append('\n') + mod = mods.child(i) + params_and_props = {} + params_and_props.update(mod.properties) + params_and_props.update(mod.parameters) + descr = None + for i in range(mod.childCount()): + if mod.child(i).name == 'description': + descr = mod.child(i) + break + lines.append(f'Mod(\'{mod.name}\',\n \'{mod.get_value()}\',\n \'{descr.get_value()}\',') + for j in range(mod.childCount()): + if j == i: + continue + lines.append(fmt_param(mod.child(j), params_and_props)) + lines.append(')') + + with open(file_name, 'w', encoding='utf-8') as configfile: + configfile.write('\n'.join(lines)) + +def read_config(file_path, log): + config = load_config(file_path, log) + node = TreeWidgetItem(NODE) + ifs = TreeWidgetItem(name='Interfaces') + mods = TreeWidgetItem(name='Modules') + node.addChild(ifs) + node.addChild(mods) + nodecfg = config.pop('node', {}) + node.set_name(nodecfg['equipment_id']) + node.set_value(nodecfg['description']) + node.parameters = get_params(NODE) + node.properties = get_props(NODE) + + iface = TreeWidgetItem(INTERFACE, nodecfg['interface']) + #act_class = get_interface_class_from_name(section_value) + #act_item.set_class_object(act_class) + ifs.addChild(iface) + for name, modcfg in config.items(): + act_item = TreeWidgetItem(MODULE, name) + mods.addChild(act_item) + cls = modcfg.pop('cls') + act_item.set_value(cls) + act_class = get_module_class_from_name(cls) + act_item.set_class_object(act_class) + act_item.parameters = get_params(act_class) + act_item.properties = get_props(act_class) + for param, options in modcfg.items(): + if not isinstance(options, dict): + prop = TreeWidgetItem(PROPERTY, param, str(options)) + act_item.addChild(prop) + else: + param = TreeWidgetItem(PARAMETER, param) + param.set_value('') + act_item.addChild(param) + for k, v in options.items(): + if k == 'value': + param.set_value(str(v)) + else: + param.addChild(TreeWidgetItem(PROPERTY, k, str(v))) + #node = get_comments(node, ifs, mods, file_path) + return node, ifs, mods + def get_value(config, section, option): value = config.get(section, option) diff --git a/frappy/gui/cfg_editor/mainwindow.py b/frappy/gui/cfg_editor/mainwindow.py index 46b7a92..1bb9f54 100644 --- a/frappy/gui/cfg_editor/mainwindow.py +++ b/frappy/gui/cfg_editor/mainwindow.py @@ -39,9 +39,10 @@ COMMENT = 'comment' class MainWindow(QMainWindow): - def __init__(self, file_path=None, parent=None): + def __init__(self, file_path=None, log=None, parent=None): super().__init__(parent) loadUi(self, 'mainwindow.ui') + self.log = log self.tabWidget.currentChanged.connect(self.tab_relevant_btns_disable) if file_path is None: self.tab_relevant_btns_disable(-1) @@ -179,7 +180,7 @@ class MainWindow(QMainWindow): QMessageBox.StandardButton.Save) def new_node(self, name, file_path=None): - node = NodeDisplay(file_path) + node = NodeDisplay(file_path, self.log) if node.created: node.tree_widget.currentItemChanged.connect(self.disable_btns) self.tabWidget.setCurrentIndex(self.tabWidget.addTab(node, name)) diff --git a/frappy/gui/cfg_editor/node_display.py b/frappy/gui/cfg_editor/node_display.py index 84e661f..ecbb0fa 100644 --- a/frappy/gui/cfg_editor/node_display.py +++ b/frappy/gui/cfg_editor/node_display.py @@ -26,10 +26,12 @@ from frappy.gui.cfg_editor.utils import loadUi class NodeDisplay(QWidget): - def __init__(self, file_path=None, parent=None): + def __init__(self, file_path=None, log=None, parent=None): super().__init__(parent) loadUi(self, 'node_display.ui') + self.log = log self.saved = bool(file_path) + self.tree_widget.log = log self.created = self.tree_widget.set_file(file_path) self.tree_widget.save_status_changed.connect(self.change_save_status) self.tree_widget.currentItemChanged.connect(self.set_scroll_area) diff --git a/frappy/gui/cfg_editor/utils.py b/frappy/gui/cfg_editor/utils.py index dc19276..27a8cd3 100644 --- a/frappy/gui/cfg_editor/utils.py +++ b/frappy/gui/cfg_editor/utils.py @@ -99,8 +99,8 @@ def get_file_paths(widget, open_file=True): dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) dialog.setFileMode(QFileDialog.FileMode.AnyFile) dialog.setWindowTitle(title) - dialog.setNameFilter('*.cfg') - dialog.setDefaultSuffix('.cfg') + dialog.setNameFilter('*.py') + dialog.setDefaultSuffix('.py') dialog.exec() return dialog.selectedFiles() diff --git a/frappy/gui/cfg_editor/widgets.py b/frappy/gui/cfg_editor/widgets.py index b472628..c1b1297 100644 --- a/frappy/gui/cfg_editor/widgets.py +++ b/frappy/gui/cfg_editor/widgets.py @@ -102,7 +102,7 @@ class TreeWidget(QTreeWidget): self.file_path = file_path if self.file_path: if os.path.isfile(file_path): - self.set_tree(read_config(self.file_path)) + self.set_tree(read_config(self.file_path, self.log)) self.emit_save_status_changed(True) return True self.file_path = None @@ -277,7 +277,7 @@ class TreeWidget(QTreeWidget): file_name = self.file_path if not self.file_path or save_as: file_name = get_file_paths(self, False)[-1] - if file_name[-4:] == '.cfg': + if file_name[-3:] == '.py': self.file_path = file_name write_config(self.file_path, self) self.emit_save_status_changed(True) diff --git a/frappy/gui/valuewidgets.py b/frappy/gui/valuewidgets.py index 19a742b..9dd1a35 100644 --- a/frappy/gui/valuewidgets.py +++ b/frappy/gui/valuewidgets.py @@ -228,7 +228,7 @@ def get_widget(datatype, readonly=False, parent=None): TupleOf: TupleWidget, StructOf: StructWidget, ArrayOf: ArrayWidget, - }.get(datatype.__class__)(datatype, readonly, parent) + }.get(datatype.__class__, TextWidget)(datatype, readonly, parent) # TODO: handle NoneOr diff --git a/frappy/lib/__init__.py b/frappy/lib/__init__.py index 2c48b3d..81fc108 100644 --- a/frappy/lib/__init__.py +++ b/frappy/lib/__init__.py @@ -21,9 +21,9 @@ # ***************************************************************************** """Define helpers""" -import re import importlib import linecache +import re import socket import sys import threading @@ -295,50 +295,68 @@ def formatException(cut=0, exc_info=None, verbose=False): return ''.join(res) -HOSTNAMEPAT = re.compile(r'[a-z0-9_.-]+$', re.IGNORECASE) # roughly checking for a valid hostname or ip address +HOSTNAMEPART = re.compile(r'^(?!-)[a-z0-9-]{1,63}(? 255: + return False + for part in host.split('.'): + if not HOSTNAMEPART.match(part): + return False + return True -def parseHostPort(host, defaultport): - """Parse host[:port] string and tuples +def validate_ipv4(addr): + """check if v4 address is valid.""" + try: + socket.inet_aton(addr) + except OSError: + return False + return True - Specify 'host[:port]' or a (host, port) tuple for the mandatory argument. - If the port specification is missing, the value of the defaultport is used. - raises TypeError in case host is neither a string nor an iterable - raises ValueError in other cases of invalid arguments +def validate_ipv6(addr): + """check if v6 address is valid.""" + try: + socket.inet_pton(socket.AF_INET6, addr) + except OSError: + return False + return True + + +def parse_ipv6_host_and_port(addr, defaultport=10767): + """ Parses IPv6 addresses with optional port. See parse_host_port for valid formats""" + if ']' in addr: + host, port = addr.rsplit(':', 1) + return host[1:-1], int(port) + if '.' in addr: + host, port = addr.rsplit('.', 1) + return host, int(port) + return (host, defaultport) + +def parse_host_port(host, defaultport=10767): + """Parses hostnames and IP (4/6) addressses. + + The accepted formats are: + - a standard hostname + - base IPv6 or 4 addresses + - 'hostname:port' + - IPv4 addresses in the form of 'IPv4:port' + - IPv6 addresses in the forms '[IPv6]:port' or 'IPv6.port' """ - if isinstance(host, str): - host, sep, port = host.partition(':') - if sep: - port = int(port) - else: - port = defaultport - else: - host, port = host - if not HOSTNAMEPAT.match(host): - raise ValueError(f'illegal host name {host!r}') - if 0 < port < 65536: - return host, port - raise ValueError(f'illegal port number: {port!r}') - - -def tcpSocket(host, defaultport, timeout=None): - """Helper for opening a TCP client socket to a remote server. - - Specify 'host[:port]' or a (host, port) tuple for the mandatory argument. - If the port specification is missing, the value of the defaultport is used. - If timeout is set to a number, the timout of the connection is set to this - number, else the socket stays in blocking mode. - """ - host, port = parseHostPort(host, defaultport) - - # open socket and set options - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - if timeout: - s.settimeout(timeout) - # connect - s.connect((host, int(port))) - return s + colons = host.count(':') + if colons == 0: # hostname/ipv4 wihtout port + port = defaultport + elif colons == 1: # hostname or ipv4 with port + host, port = host.split(':') + port = int(port) + else: # ipv6 + host, port = parse_ipv6_host_and_port(host, defaultport) + if (validate_ipv4(host) or validate_hostname(host) or validate_ipv6(host)) \ + and 0 < port < 65536: + return (host, port) + raise ValueError(f'invalid host {host!r} or port {port}') # keep a reference to socket to avoid (interpreter) shut-down problems diff --git a/frappy/lib/asynconn.py b/frappy/lib/asynconn.py index b09dc8f..143d58a 100644 --- a/frappy/lib/asynconn.py +++ b/frappy/lib/asynconn.py @@ -35,7 +35,7 @@ import time import re from frappy.errors import CommunicationFailedError, ConfigError -from frappy.lib import closeSocket, parseHostPort, tcpSocket +from frappy.lib import closeSocket, parse_host_port try: from serial import Serial @@ -60,7 +60,7 @@ class AsynConn: if not iocls: # try tcp, if scheme not given try: - parseHostPort(uri, 1) # check hostname only + parse_host_port(uri, 1) # check hostname only except ValueError: if 'COM' in uri: raise ValueError("the correct uri for a COM port is: " @@ -175,7 +175,9 @@ class AsynTcp(AsynConn): if uri.startswith('tcp://'): uri = uri[6:] try: - self.connection = tcpSocket(uri, self.default_settings.get('port'), self.timeout) + + host, port = parse_host_port(uri, self.default_settings.get('port')) + self.connection = socket.create_connection((host, port), timeout=self.timeout) except (ConnectionRefusedError, socket.gaierror, socket.timeout) as e: # indicate that retrying might make sense raise CommunicationFailedError(str(e)) from None diff --git a/frappy/protocol/interface/tcp.py b/frappy/protocol/interface/tcp.py index 988b94b..1a28fd3 100644 --- a/frappy/protocol/interface/tcp.py +++ b/frappy/protocol/interface/tcp.py @@ -21,22 +21,22 @@ # ***************************************************************************** """provides tcp interface to the SECoP Server""" +import errno +import os import socket import socketserver import sys import threading import time -import errno -import os from frappy.datatypes import BoolType, StringType from frappy.errors import SECoPError -from frappy.lib import formatException, \ - formatExtendedStack, formatExtendedTraceback +from frappy.lib import formatException, formatExtendedStack, \ + formatExtendedTraceback from frappy.properties import Property from frappy.protocol.interface import decode_msg, encode_msg_frame, get_msg -from frappy.protocol.messages import ERRORPREFIX, \ - HELPREPLY, HELPREQUEST, HelpMessage +from frappy.protocol.messages import ERRORPREFIX, HELPREPLY, HELPREQUEST, \ + HelpMessage DEF_PORT = 10767 MESSAGE_READ_SIZE = 1024 @@ -57,7 +57,7 @@ class TCPRequestHandler(socketserver.BaseRequestHandler): clientaddr = self.client_address serverobj = self.server - self.log.info("handling new connection from %s:%d" % clientaddr) + self.log.info("handling new connection from %s", format_address(clientaddr)) data = b'' # notify dispatcher of us @@ -160,7 +160,7 @@ class TCPRequestHandler(socketserver.BaseRequestHandler): def finish(self): """called when handle() terminates, i.e. the socket closed""" - self.log.info('closing connection from %s:%d' % self.client_address) + self.log.info('closing connection from %s', format_address(self.client_address)) # notify dispatcher self.server.dispatcher.remove_connection(self) # close socket @@ -171,8 +171,28 @@ class TCPRequestHandler(socketserver.BaseRequestHandler): finally: self.request.close() +class DualStackTCPServer(socketserver.ThreadingTCPServer): + """Subclassed to provide IPv6 capabilities as socketserver only uses IPv4""" + def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True, enable_ipv6=False): + super().__init__( + server_address, RequestHandlerClass, bind_and_activate=False) -class TCPServer(socketserver.ThreadingTCPServer): + # override default socket + if enable_ipv6: + self.address_family = socket.AF_INET6 + self.socket = socket.socket(self.address_family, + self.socket_type) + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + if bind_and_activate: + try: + self.server_bind() + self.server_activate() + except: + self.server_close() + raise + + +class TCPServer(DualStackTCPServer): daemon_threads = True # on windows, 'reuse_address' means that several servers might listen on # the same port, on the other hand, a port is not blocked after closing @@ -191,13 +211,16 @@ class TCPServer(socketserver.ThreadingTCPServer): self.name = name self.log = logger port = int(options.pop('uri').split('://', 1)[-1]) + enable_ipv6 = options.pop('ipv6', False) self.detailed_errors = options.pop('detailed_errors', False) self.log.info("TCPServer %s binding to port %d", name, port) for ntry in range(5): try: - socketserver.ThreadingTCPServer.__init__( - self, ('0.0.0.0', port), TCPRequestHandler, bind_and_activate=True) + DualStackTCPServer.__init__( + self, ('', port), TCPRequestHandler, + bind_and_activate=True, enable_ipv6=enable_ipv6 + ) break except OSError as e: if e.args[0] == errno.EADDRINUSE: # address already in use @@ -217,3 +240,11 @@ class TCPServer(socketserver.ThreadingTCPServer): def __exit__(self, *args): self.server_close() + +def format_address(addr): + if len(addr) == 2: + return '%s:%d' % addr + address, port = addr[0:2] + if address.startswith('::ffff'): + return '%s:%d' % (address[7:], port) + return '[%s]:%d' % (address, port) diff --git a/frappy/server.py b/frappy/server.py index 0d195a1..f0cfb80 100644 --- a/frappy/server.py +++ b/frappy/server.py @@ -125,6 +125,9 @@ class Server: def unknown_options(self, cls, options): return f"{cls.__name__} class don't know how to handle option(s): {', '.join(options)}" + def restart_hook(self): + pass + def run(self): while self._restart: self._restart = False diff --git a/frappy_mlz/entangle.py b/frappy_mlz/entangle.py index 432c26b..16783a7 100644 --- a/frappy_mlz/entangle.py +++ b/frappy_mlz/entangle.py @@ -460,6 +460,12 @@ class AnalogOutput(PyTangoDevice, Drivable): _history = () _timeout = None _moving = False + __main_unit = None + + def applyMainUnit(self, mainunit): + # called from __init__ method + # replacement of '$' by main unit must be done later + self.__main_unit = mainunit def initModule(self): super().initModule() @@ -469,12 +475,18 @@ class AnalogOutput(PyTangoDevice, Drivable): def startModule(self, start_events): super().startModule(start_events) - # query unit from tango and update value property - attrInfo = self._dev.attribute_query('value') - # prefer configured unit if nothing is set on the Tango device, else - # update - if attrInfo.unit != 'No unit': - self.accessibles['value'].datatype.setProperty('unit', attrInfo.unit) + try: + # query unit from tango and update value property + attrInfo = self._dev.attribute_query('value') + # prefer configured unit if nothing is set on the Tango device, else + # update + if attrInfo.unit != 'No unit': + self.accessibles['value'].datatype.setProperty('unit', attrInfo.unit) + self.__main_unit = attrInfo.unit + except Exception as e: + self.log.error(e) + if self.__main_unit: + super().applyMainUnit(self.__main_unit) def doPoll(self): super().doPoll() @@ -543,7 +555,7 @@ class AnalogOutput(PyTangoDevice, Drivable): return self.abslimits[1] def __getusermin(self): - return self.userlimits[0] + return max(self.userlimits[0], self.abslimits[0]) def __setusermin(self, value): self.userlimits = (value, self.userlimits[1]) @@ -551,7 +563,7 @@ class AnalogOutput(PyTangoDevice, Drivable): usermin = property(__getusermin, __setusermin) def __getusermax(self): - return self.userlimits[1] + return min(self.userlimits[1], self.abslimits[1]) def __setusermax(self, value): self.userlimits = (self.userlimits[0], value) diff --git a/frappy_psi/historywriter.py b/frappy_psi/historywriter.py index a3667a4..7e39a2e 100644 --- a/frappy_psi/historywriter.py +++ b/frappy_psi/historywriter.py @@ -31,7 +31,7 @@ def make_cvt_list(dt, tail=''): tail is a postfix to be appended in case of tuples and structs """ if isinstance(dt, (EnumType, IntRange, BoolType)): - return[(int, tail, {type: 'NUM'})] + return[(int, tail, {'type': 'NUM'})] if isinstance(dt, (FloatRange, ScaledInteger)): return [(dt.import_value, tail, {'type': 'NUM', 'unit': dt.unit, 'period': 5} if dt.unit else {})] diff --git a/test/test_lib.py b/test/test_lib.py index 104d5c2..03c09a9 100644 --- a/test/test_lib.py +++ b/test/test_lib.py @@ -21,21 +21,28 @@ # ***************************************************************************** import pytest -from frappy.lib import parseHostPort + +from frappy.lib import parse_host_port @pytest.mark.parametrize('hostport, defaultport, result', [ - (('box.psi.ch', 9999), 1, ('box.psi.ch', 9999)), - (('/dev/tty', 9999), 1, None), + ('box.psi.ch:9999', 1, ('box.psi.ch', 9999)), + ('/dev/tty:9999', 1, None), ('localhost:10767', 1, ('localhost', 10767)), ('www.psi.ch', 80, ('www.psi.ch', 80)), ('/dev/ttyx:2089', 10767, None), ('COM4:', 2089, None), - ('underscore_valid.123.hyphen-valid.com', 80, ('underscore_valid.123.hyphen-valid.com', 80)), + ('123.hyphen-valid.com', 80, ('123.hyphen-valid.com', 80)), + ('underscore_invalid.123.hyphen-valid.com:10000', 80, None), + ('::1.1111', 2, ('::1', 1111)), + ('[2e::fe]:1', 50, ('2e::fe', 1)), + ('127.0.0.1:50', 1337, ('127.0.0.1', 50)), + ('234.40.128.3:13212', 1337, ('234.40.128.3', 13212)), + ]) def test_parse_host(hostport, defaultport, result): if result is None: with pytest.raises(ValueError): - parseHostPort(hostport, defaultport) + parse_host_port(hostport, defaultport) else: - assert result == parseHostPort(hostport, defaultport) + assert result == parse_host_port(hostport, defaultport) From 33142416314b8030cd33f24ffdea4b5582a34a66 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Fri, 5 May 2023 13:16:41 +0200 Subject: [PATCH 2/7] [WIP] phytron improvements - Limits - offset - power cycle behaviour Change-Id: Id2f717c362cd7e1e37f180c8130b0e086e724389 --- frappy_psi/phytron.py | 144 +++++++++++++++++++++--------------------- 1 file changed, 72 insertions(+), 72 deletions(-) diff --git a/frappy_psi/phytron.py b/frappy_psi/phytron.py index fe619fc..ca43725 100644 --- a/frappy_psi/phytron.py +++ b/frappy_psi/phytron.py @@ -22,11 +22,12 @@ """driver for phytron motors""" +import time from frappy.core import Done, Command, EnumType, FloatRange, IntRange, \ HasIO, Parameter, Property, Drivable, PersistentMixin, PersistentParam, \ - StringIO, StringType, TupleOf -from frappy.errors import CommunicationFailedError, HardwareError, BadValueError -from frappy.lib import clamp + StringIO, StringType, IDLE, BUSY, ERROR, Limit +from frappy.errors import CommunicationFailedError, HardwareError +from frappy.features import HasOffset class PhytronIO(StringIO): @@ -54,43 +55,35 @@ class PhytronIO(StringIO): return reply[1:] -class Motor(PersistentMixin, HasIO, Drivable): +class Motor(HasOffset, PersistentMixin, HasIO, Drivable): axis = Property('motor axis X or Y', StringType(), default='X') address = Property('address', IntRange(0, 15), default=0) + circumference = Property('cirumference for rotations or zero for linear', FloatRange(0), default=360) encoder_mode = Parameter('how to treat the encoder', EnumType('encoder', NO=0, READ=1, CHECK=2), default=1, readonly=False) - value = Parameter('angle', FloatRange(unit='deg')) + value = PersistentParam('angle', FloatRange(unit='deg')) + status = PersistentParam() target = Parameter('target angle', FloatRange(unit='deg'), readonly=False) speed = Parameter('', FloatRange(0, 20, unit='deg/s'), readonly=False) accel = Parameter('', FloatRange(2, 250, unit='deg/s/s'), readonly=False) encoder_tolerance = Parameter('', FloatRange(unit='deg'), readonly=False, default=0.01) - offset = PersistentParam('', FloatRange(unit='deg'), readonly=False, default=0) sign = PersistentParam('', IntRange(-1,1), readonly=False, default=1) encoder = Parameter('encoder reading', FloatRange(unit='deg')) backlash = Parameter("""backlash compensation\n offset for always approaching from the same side""", FloatRange(unit='deg'), readonly=False, default=0) - abslimits = Parameter('abs limits (raw values)', default=(0, 0), - datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg'))) - userlimits = PersistentParam('user limits', readonly=False, default=(0, 0), initwrite=True, - datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg'))) + target_min = Limit() + target_max = Limit() + alive_time = PersistentParam('alive time for detecting restarts', + FloatRange(), default=0) # export=False ioClass = PhytronIO fast_poll = 0.1 _backlash_pending = False _mismatch_count = 0 - _rawlimits = None _step_size = None # degree / step - - def earlyInit(self): - super().earlyInit() - if self.abslimits == (0, 0): - self.abslimits = -9e99, 9e99 - if self.userlimits == (0, 0): - self._rawlimits = self.abslimits - self.read_userlimits() - self.loadParameters() + _reset_needed = False def get(self, cmd): return self.communicate('%x%s%s' % (self.address, self.axis, cmd)) @@ -111,9 +104,24 @@ class Motor(PersistentMixin, HasIO, Drivable): self.set(cmd, value) return self.get(query) + def read_alive_time(self): + now = time.time() + axisbit = 1 << int(self.axis == 'Y') + active_axes = int(self.get('P37R')) # adr 37 is a custom address with no internal meaning + if not (axisbit & active_axes): # power cycle detected and this axis not yet active + self.set('P37S', axisbit | active_axes) # activate axis + if now < self.alive_time + 7 * 24 * 3600: # the device was running within last week + # inform the user about the loss of position by the need of doing reset_error + self._reset_needed = True + else: # do reset silently + self.reset_error() + self.alive_time = now + self.saveParameters() + return now + def read_value(self): prev_enc = self.encoder - pos = float(self.get('P20R')) * self.sign - self.offset + pos = float(self.get('P20R')) * self.sign if self.encoder_mode != 'NO': enc = self.read_encoder() else: @@ -122,23 +130,25 @@ class Motor(PersistentMixin, HasIO, Drivable): status = status[0:4] if self.axis == 'X' else status[4:8] self.log.debug('run %s enc %s end %s', status[1], status[2], status[3]) status = self.get('=H') - if status == 'N': + if status == 'N': # not at target if self.encoder_mode == 'CHECK': e1, e2 = sorted((prev_enc, enc)) if e1 - self.encoder_tolerance <= pos <= e2 + self.encoder_tolerance: - self.status = self.Status.BUSY, 'driving' + self.status = BUSY, 'driving' else: self.log.error('encoder lag: %g not within %g..%g', pos, e1, e2) self.get('S') # stop - self.status = self.Status.ERROR, 'encoder lag error' + self.status = ERROR, 'encoder lag error' + self.value = pos + self.saveParameters() self.setFastPoll(False) else: - self.status = self.Status.BUSY, 'driving' + self.status = BUSY, 'driving' else: if self._backlash_pending: # drive to real target - self.set('A', self.sign * (self.target + self.offset)) + self.set('A', self.sign * self.target) self._backlash_pending = False return pos if (self.encoder_mode == 'CHECK' and @@ -148,17 +158,19 @@ class Motor(PersistentMixin, HasIO, Drivable): else: self.log.error('encoder mismatch: abs(%g - %g) < %g', enc, pos, self.encoder_tolerance) - self.status = self.Status.ERROR, 'encoder does not match pos' + self.status = ERROR, 'encoder does not match pos' else: self._mismatch_count = 0 - self.status = self.Status.IDLE, '' + self.status = IDLE, '' + self.value = pos + self.saveParameters() self.setFastPoll(False) return pos def read_encoder(self): if self.encoder_mode == 'NO': return self.value - return float(self.get('P22R')) * self.sign - self.offset + return float(self.get('P22R')) * self.sign def write_sign(self, value): self.sign = value @@ -187,68 +199,56 @@ class Motor(PersistentMixin, HasIO, Drivable): self.get_step_size() return float(self.set_get('P15S', round(value / self._step_size), 'P15R')) * self._step_size - def _check_limits(self, *values): - for name, (mn, mx) in ('user', self._rawlimits), ('abs', self.abslimits): - mn -= self.offset - mx -= self.offset - for v in values: - if not mn <= v <= mx: - raise BadValueError('%s limits violation: %g <= %g <= %g' % (name, mn, v, mx)) - v += self.offset + def check_target(self, value): + self.checkLimits(value) + self.checkLimits(value + self.backlash) def write_target(self, value): - if self.status[0] == self.Status.ERROR: + self.read_alive_time() + if self._reset_needed: + self.status = ERROR, 'reset needed after power up (probably position lost)' + raise HardwareError(self.status[1]) + if self.status[0] == ERROR: raise HardwareError('need reset') - self.status = self.Status.BUSY, 'changed target' - self._check_limits(value, value + self.backlash) + self.status = BUSY, 'changed target' + self.saveParameters() if self.backlash: # drive first to target + backlash # we do not optimize when already driving from the right side self._backlash_pending = True - self.set('A', self.sign * (value + self.offset + self.backlash)) + self.set('A', self.sign * (value + self.backlash)) else: - self.set('A', self.sign * (value + self.offset)) + self.set('A', self.sign * value) self.setFastPoll(True, self.fast_poll) return value - def read_userlimits(self): - return self._rawlimits[0] - self.offset, self._rawlimits[1] - self.offset - - def write_userlimits(self, value): - self._rawlimits = [clamp(self.abslimits[0], v + self.offset, self.abslimits[1]) for v in value] - value = self.read_userlimits() - self.saveParameters() - return value - - def write_offset(self, value): - self.offset = value - self.read_userlimits() - self.saveParameters() - return Done - def stop(self): self.get('S') @Command - def reset(self): + def reset_error(self): """Reset error, set position to encoder""" self.read_value() - if self.status[0] == self.Status.ERROR: - enc = self.encoder + self.offset - pos = self.value + self.offset - if abs(enc - pos) > self.encoder_tolerance: - if enc < 0: - # assume we have a rotation (not a linear motor) - while enc < 0: - self.offset += 360 - enc += 360 - self.set('P22S', enc * self.sign) - self.saveParameters() - self.set('P20S', enc * self.sign) # set pos to encoder + if self.status[0] == ERROR or self._reset_needed: + newenc = enc = self.encoder + pos = self.value + if abs(enc - pos) > self.encoder_tolerance or self.encoder_mode == 'NO': + if self.circumference: + # bring encoder value either within or as close as possible to the given range + if enc < self.target_min: + mid = self.target_min + 0.5 * min(self.target_max - self.target_min, self.circumference) + elif enc > self.target_max: + mid = self.target_max - 0.5 * min(self.target_max - self.target_min, self.circumference) + else: + mid = enc + newenc += round((mid - enc) / self.circumference) * self.circumference + if newenc != enc: + self.set('P22S', newenc * self.sign) + if newenc != pos: + self.set('P20S', newenc * self.sign) # set pos to encoder self.read_value() - # self.status = self.Status.IDLE, '' + self._reset_needed = False # TODO: # '=E' electronics status # '=I+' / '=I-': limit switches -# use P37 to determine if restarted From f528cc480858175e33018463acd133c6f0c294bd Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Thu, 4 May 2023 16:26:01 +0200 Subject: [PATCH 3/7] fixes on HasConvergence and HasOutputModule - HasConvergence must inherit from HasStates - control_active should have a default Change-Id: Ic8b430003fdb746bf76782b74fa04e43c700c2e2 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31023 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker From ca6b7a65c0b44bf57eda0837fcd29ce45f401daa Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Mon, 1 May 2023 11:26:52 +0200 Subject: [PATCH 4/7] improve mercury temperature loop - remove appearance of Done - add auto flow - try up to 3 times in 'change' method if read back does not match Change-Id: I98928307bda87190d34aed663023b157311d4495 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/30981 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker --- .pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index 6180ea0..da7f1b1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -218,7 +218,7 @@ max-branches=50 max-statements=150 # Maximum number of parents for a class (see R0901). -max-parents=25 +max-parents=20 # Maximum number of attributes for a class (see R0902). max-attributes=50 From 85166344d2f4e09b53c4e5cb92bedc75ed522177 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Thu, 13 Apr 2023 16:02:43 +0200 Subject: [PATCH 5/7] improve interactive client - remove irrelevant traceback on remote errors - add run() function to execute scripts - when started with bin/frappy-cli, use separate namespace Change-Id: Ic808a76fa76ecd8d814d52b15a6d7d2203c6a2f3 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/30957 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker From 3b95013b6904700e1c890360a0c4722752cab368 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Thu, 4 May 2023 12:50:25 +0200 Subject: [PATCH 6/7] improve and fix errors with parameter limits - in order to work properly, readonly=True in limit parameters has to be set before creating the write_* method - more explicit: Use e.g. target_max=Limit() - fix an error in the loop over the base classes when creating the check_* method - more concise error message when a limit is violated + fix an error in playground when using persistent parameters Change-Id: Ibd557b55d6c0d9a2612cda4460b16e3c70e1bc9e Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31017 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker --- frappy/core.py | 4 +-- frappy/modules.py | 59 +++++++++++++++++++++----------------------- frappy/params.py | 29 ++++++++++++++++++++++ frappy/persistent.py | 6 ++++- frappy/playground.py | 1 + test/test_modules.py | 16 ++++++------ 6 files changed, 73 insertions(+), 42 deletions(-) diff --git a/frappy/core.py b/frappy/core.py index 72e201b..02948d4 100644 --- a/frappy/core.py +++ b/frappy/core.py @@ -31,11 +31,11 @@ from frappy.datatypes import ArrayOf, BLOBType, BoolType, EnumType, \ from frappy.lib.enum import Enum from frappy.modules import Attached, Communicator, \ Done, Drivable, Feature, Module, Readable, Writable, HasAccessibles -from frappy.params import Command, Parameter +from frappy.params import Command, Parameter, Limit from frappy.properties import Property from frappy.proxy import Proxy, SecNode, proxy_class from frappy.io import HasIO, StringIO, BytesIO, HasIodev # TODO: remove HasIodev (legacy stuff) -from frappy.persistent import PersistentMixin, PersistentParam +from frappy.persistent import PersistentMixin, PersistentParam, PersistentLimit from frappy.rwhandler import ReadHandler, WriteHandler, CommonReadHandler, \ CommonWriteHandler, nopoll diff --git a/frappy/modules.py b/frappy/modules.py index 22359b8..fafd3b4 100644 --- a/frappy/modules.py +++ b/frappy/modules.py @@ -34,7 +34,7 @@ from frappy.errors import BadValueError, CommunicationFailedError, ConfigError, ProgrammingError, SECoPError, secop_error, RangeError from frappy.lib import formatException, mkthread, UniqueObject from frappy.lib.enum import Enum -from frappy.params import Accessible, Command, Parameter +from frappy.params import Accessible, Command, Parameter, Limit from frappy.properties import HasProperties, Property from frappy.logging import RemoteLogHandler, HasComlog @@ -161,7 +161,7 @@ class HasAccessibles(HasProperties): # find the base class, where the parameter is defined first. # we have to check all bases, as they may not be treated yet when # not inheriting from HasAccessibles - base = next(b for b in reversed(base.__mro__) if limname in b.__dict__) + base = next(b for b in reversed(cls.__mro__) if limname in b.__dict__) if cname not in base.__dict__: # there is no check method yet at this class # add check function to the class where the limit was defined @@ -431,28 +431,19 @@ class Module(HasAccessibles): self.valueCallbacks[pname] = [] self.errorCallbacks[pname] = [] - if not pobj.hasDatatype(): - head, _, postfix = pname.rpartition('_') - if postfix not in ('min', 'max', 'limits'): - errors.append(f'{pname} needs a datatype') - continue - # when datatype is not given, properties are set automagically - pobj.setProperty('readonly', False) - baseparam = self.parameters.get(head) + if isinstance(pobj, Limit): + basepname = pname.rpartition('_')[0] + baseparam = self.parameters.get(basepname) if not baseparam: - errors.append(f'parameter {pname!r} is given, but not {head!r}') + errors.append(f'limit {pname!r} is given, but not {basepname!r}') continue - dt = baseparam.datatype - if dt is None: + if baseparam.datatype is None: continue # an error will be reported on baseparam - if postfix == 'limits': - pobj.setProperty('datatype', TupleOf(dt, dt)) - pobj.setProperty('default', (dt.min, dt.max)) - else: - pobj.setProperty('datatype', dt) - pobj.setProperty('default', getattr(dt, postfix)) - if not pobj.description: - pobj.setProperty('description', f'limit for {pname}') + pobj.set_datatype(baseparam.datatype) + + if not pobj.hasDatatype(): + errors.append(f'{pname} needs a datatype') + continue if pobj.value is None: if pobj.needscfg: @@ -805,11 +796,11 @@ class Module(HasAccessibles): raise ValueError('remote handler not found') self.remoteLogHandler.set_conn_level(self, conn, level) - def checkLimits(self, value, parametername='target'): + def checkLimits(self, value, pname='target'): """check for limits - :param value: the value to be checked for _min <= value <= _max - :param parametername: parameter name, default is 'target' + :param value: the value to be checked for _min <= value <= _max + :param pname: parameter name, default is 'target' raises RangeError in case the value is not valid @@ -818,14 +809,20 @@ class Module(HasAccessibles): when no automatic super call is desired. """ try: - min_, max_ = getattr(self, parametername + '_limits') + min_, max_ = getattr(self, pname + '_limits') + if not min_ <= value <= max_: + raise RangeError(f'{pname} outside {pname}_limits') + return except AttributeError: - min_ = getattr(self, parametername + '_min', float('-inf')) - max_ = getattr(self, parametername + '_max', float('inf')) - if not min_ <= value <= max_: - if min_ > max_: - raise RangeError(f'invalid limits: [{min_:g}, {max_:g}]') - raise RangeError(f'limits violation: {value:g} outside [{min_:g}, {max_:g}]') + pass + min_ = getattr(self, pname + '_min', float('-inf')) + max_ = getattr(self, pname + '_max', float('inf')) + if min_ > max_: + raise RangeError(f'invalid limits: {pname}_min > {pname}_max') + if value < min_: + raise RangeError(f'{pname} below {pname}_min') + if value > max_: + raise RangeError(f'{pname} above {pname}_max') class Readable(Module): diff --git a/frappy/params.py b/frappy/params.py index 199198c..526738c 100644 --- a/frappy/params.py +++ b/frappy/params.py @@ -514,6 +514,35 @@ class Command(Accessible): return result[:-1] + f', {self.func!r})' if self.func else result +class Limit(Parameter): + """a special limit parameter""" + POSTFIXES = {'min', 'max', 'limits'} # allowed postfixes + + def __set_name__(self, owner, name): + super().__set_name__(owner, name) + head, _, postfix = name.rpartition('_') + if postfix not in self.POSTFIXES: + raise ProgrammingError(f'Limit name must end with one of {self.POSTFIXES}') + if 'readonly' not in self.propertyValues: + self.readonly = False + if not self.description: + self.description = f'limit for {head}' + if self.export.startswith('_') and PREDEFINED_ACCESSIBLES.get(head): + self.export = self.export[1:] + + def set_datatype(self, datatype): + if self.hasDatatype(): + return # the programmer is responsible that a given datatype is correct + postfix = self.name.rpartition('_')[-1] + postfix = self.name.rpartition('_')[-1] + if postfix == 'limits': + self.datatype = TupleOf(datatype, datatype) + self.default = (datatype.min, datatype.max) + else: # min, max + self.datatype = datatype + self.default = getattr(datatype, postfix) + + # list of predefined accessibles with their type PREDEFINED_ACCESSIBLES = { 'value': Parameter, diff --git a/frappy/persistent.py b/frappy/persistent.py index a2ed722..03e2499 100644 --- a/frappy/persistent.py +++ b/frappy/persistent.py @@ -57,7 +57,7 @@ import json from frappy.lib import generalConfig from frappy.datatypes import EnumType -from frappy.params import Parameter, Property, Command +from frappy.params import Parameter, Property, Command, Limit from frappy.modules import Module @@ -67,6 +67,10 @@ class PersistentParam(Parameter): given = False +class PersistentLimit(Limit, Parameter): + pass + + class PersistentMixin(Module): persistentData = None # dict containing persistent data after startup diff --git a/frappy/playground.py b/frappy/playground.py index 4664ca5..07061f6 100644 --- a/frappy/playground.py +++ b/frappy/playground.py @@ -81,6 +81,7 @@ class Dispatcher: def __init__(self, name, log, opts, srv): self.log = log self._modules = {} + self.equipment_id = opts.pop('equipment_id', name) def announce_update(self, modulename, pname, pobj): if pobj.readerror: diff --git a/test/test_modules.py b/test/test_modules.py index 1b05137..a6dda8c 100644 --- a/test/test_modules.py +++ b/test/test_modules.py @@ -29,7 +29,7 @@ import pytest from frappy.datatypes import BoolType, FloatRange, StringType, IntRange, ScaledInteger from frappy.errors import ProgrammingError, ConfigError, RangeError from frappy.modules import Communicator, Drivable, Readable, Module -from frappy.params import Command, Parameter +from frappy.params import Command, Parameter, Limit from frappy.rwhandler import ReadHandler, WriteHandler, nopoll from frappy.lib import generalConfig @@ -795,17 +795,17 @@ stdlim = { class Lim(Module): a = Parameter('', FloatRange(-10, 10), readonly=False, default=0) - a_min = Parameter() - a_max = Parameter() + a_min = Limit() + a_max = Limit() b = Parameter('', FloatRange(0, None), readonly=False, default=0) - b_min = Parameter() + b_min = Limit() c = Parameter('', IntRange(None, 100), readonly=False, default=0) - c_max = Parameter() + c_max = Limit() d = Parameter('', FloatRange(-5, 5), readonly=False, default=0) - d_limits = Parameter() + d_limits = Limit() e = Parameter('', IntRange(0, 8), readonly=False, default=0) @@ -872,8 +872,8 @@ def test_limit_inheritance(): raise ValueError('value is not a multiple of 0.25') class Mixin: - a_min = Parameter() - a_max = Parameter() + a_min = Limit() + a_max = Limit() class Mod(Mixin, Base): def check_a(self, value): From 6b78e69dbeef41f53f2f6048f4babe714b357f50 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Fri, 5 May 2023 13:23:14 +0200 Subject: [PATCH 7/7] add HasOffset feature + remove all other unused features Change-Id: Idff48bdabf51e7fa23547eac761f11320c41861c --- frappy/features.py | 109 ++------------------------------------------- 1 file changed, 4 insertions(+), 105 deletions(-) diff --git a/frappy/features.py b/frappy/features.py index 5124d8b..0c7edd2 100644 --- a/frappy/features.py +++ b/frappy/features.py @@ -22,115 +22,14 @@ """Define Mixin Features for real Modules implemented in the server""" -from frappy.datatypes import FloatRange, TupleOf -from frappy.core import Drivable, Feature, \ - Parameter, PersistentParam, Readable -from frappy.errors import RangeError, ConfigError -from frappy.lib import clamp +from frappy.datatypes import FloatRange +from frappy.core import Feature, PersistentParam -class HasSimpleOffset(Feature): +class HasOffset(Feature): """has a client side offset parameter this is just a storage! """ - featureName = 'HasOffset' offset = PersistentParam('offset (physical value + offset = HW value)', - FloatRange(unit='deg'), readonly=False, default=0) - - def write_offset(self, value): - self.offset = value - if isinstance(self, HasLimits): - self.read_limits() - if isinstance(self, Readable): - self.read_value() - if isinstance(self, Drivable): - self.read_target() - self.saveParameters() - return value - - -class HasTargetLimits(Feature): - """user limits - - implementation to be done in the subclass - according to standard - """ - target_limits = PersistentParam('user limits', readonly=False, default=(-9e99, 9e99), - datatype=TupleOf(FloatRange(unit='deg'), FloatRange(unit='deg'))) - _limits = None - - def apply_offset(self, sign, *values): - if isinstance(self, HasOffset): - return tuple(v + sign * self.offset for v in values) - return values - - def earlyInit(self): - super().earlyInit() - # make limits valid - _limits = self.apply_offset(1, *self.limits) - self._limits = tuple(clamp(self.abslimits[0], v, self.abslimits[1]) for v in _limits) - self.read_limits() - - def checkProperties(self): - pname = 'target' if isinstance(self, Drivable) else 'value' - dt = self.parameters[pname].datatype - min_, max_ = self.abslimits - t_min, t_max = self.apply_offset(1, dt.min, dt.max) - if t_min > max_ or t_max < min_: - raise ConfigError(f'abslimits not within {pname} range') - self.abslimits = clamp(t_min, min_, t_max), clamp(t_min, max_, t_max) - super().checkProperties() - - def read_limits(self): - return self.apply_offset(-1, *self._limits) - - def write_limits(self, value): - min_, max_ = self.apply_offset(-1, *self.abslimits) - if not min_ <= value[0] <= value[1] <= max_: - if value[0] > value[1]: - raise RangeError(f'invalid interval: {value!r}') - raise RangeError(f'limits not within abs limits [{min_:g}, {max_:g}]') - self.limits = value - self.saveParameters() - return self.limits - - def check_limits(self, value): - """check if value is valid""" - min_, max_ = self.target_limits - if not min_ <= value <= max_: - raise RangeError(f'limits violation: {value:g} outside [{min_:g}, {max_:g}]') - - -# --- legacy mixins, not agreed as standard --- - -class HasOffset(Feature): - """has an offset parameter - - implementation to be done in the subclass - """ - offset = Parameter('offset (physical value + offset = HW value)', - FloatRange(unit='$'), readonly=False, default=0) - - -class HasLimits(Feature): - """user limits - - implementation to be done in the subclass - - for a drivable, abslimits is roughly the same as the target datatype limits, - except for the offset - """ - target_limits = Parameter('user limits for target', readonly=False, default=(-9e99, 9e99), - datatype=TupleOf(FloatRange(unit='$'), FloatRange(unit='$'))) - - def earlyInit(self): - super().earlyInit() - dt = self.parameters['target'].datatype - self.target_limits = dt.min, dt.max - - def check_limits(self, value): - """check if value is valid""" - min_, max_ = self.target_limits - if not min_ <= value <= max_: - raise RangeError('limits violation: %g outside [%g, %g]' % (value, min_, max_)) + FloatRange(unit='$'), readonly=False, default=0)