From db9ce0202890d872d6949549d3eb2f5b4cc98d60 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Fri, 18 Aug 2023 16:27:55 +0200 Subject: [PATCH 01/14] Revert "revert commits done before MZ holidays" This reverts commit d2885bdd72331b60b5a6f26e0c52683d110406b5. --- .pylintrc | 2 + bin/frappy-cfg-editor | 1 + bin/frappy-server | 16 +-- cfg/seop_cfg.py | 43 +++++++ cfg/test_cfg.py | 16 +++ debian/changelog | 46 +++++++ frappy/dynamic.py | 41 ++++++ frappy/lib/__init__.py | 9 +- frappy/modules.py | 20 ++- frappy/params.py | 2 +- frappy/protocol/dispatcher.py | 97 +++++++++++++- frappy/server.py | 203 ++++++++++++++++------------- frappy/structparam.py | 164 ++++++++++++++++++++++++ frappy/version.py | 9 +- frappy_demo/cryo.py | 2 +- frappy_demo/test.py | 25 +++- frappy_mlz/seop.py | 234 ++++++++++++++++++++++++++++++++++ frappy_psi/thermofisher.py | 192 +++++++++++----------------- test/test_attach.py | 2 - test/test_config.py | 141 ++++++++++++++++++++ test/test_ctrlpars.py | 132 +++++++++++++++++++ test/test_lib.py | 18 ++- test/test_server.py | 55 ++++++++ 23 files changed, 1236 insertions(+), 234 deletions(-) create mode 100644 cfg/seop_cfg.py create mode 100644 frappy/dynamic.py create mode 100644 frappy/structparam.py create mode 100644 frappy_mlz/seop.py create mode 100644 test/test_config.py create mode 100644 test/test_ctrlpars.py create mode 100644 test/test_server.py diff --git a/.pylintrc b/.pylintrc index f166b5f..f8a8767 100644 --- a/.pylintrc +++ b/.pylintrc @@ -53,6 +53,8 @@ disable=missing-docstring ,unidiomatic-typecheck ,undefined-loop-variable ,consider-using-f-string + ,use-dict-literal + [REPORTS] # Set the output format. Available formats are text, parseable, colorized, msvs diff --git a/bin/frappy-cfg-editor b/bin/frappy-cfg-editor index fbc8ae7..a2c58fa 100755 --- a/bin/frappy-cfg-editor +++ b/bin/frappy-cfg-editor @@ -31,6 +31,7 @@ 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 diff --git a/bin/frappy-server b/bin/frappy-server index 3d6993f..d1c5cf9 100755 --- a/bin/frappy-server +++ b/bin/frappy-server @@ -1,6 +1,5 @@ #!/usr/bin/env python3 # pylint: disable=invalid-name -# -*- coding: utf-8 -*- # ***************************************************************************** # # This program is free software; you can redistribute it and/or modify it under @@ -23,8 +22,8 @@ # # ***************************************************************************** -import sys import argparse +import sys from os import path # Add import path for inplace usage @@ -61,8 +60,9 @@ def parseArgv(argv): action='store', help="comma separated list of cfg files,\n" "defaults to .\n" - "cfgfiles given without '.cfg' extension are searched in the configuration directory, " - "else they are treated as path names", + "cfgfiles given without '.cfg' extension are searched" + " in the configuration directory," + " else they are treated as path names", default=None) parser.add_argument('-g', '--gencfg', @@ -96,15 +96,13 @@ def main(argv=None): generalConfig.init(args.gencfg) logger.init(loglevel) - srv = Server(args.name, logger.log, cfgfiles=args.cfgfiles, interface=args.port, testonly=args.test) + srv = Server(args.name, logger.log, cfgfiles=args.cfgfiles, + interface=args.port, testonly=args.test) if args.daemonize: srv.start() else: - try: - srv.run() - except KeyboardInterrupt: - pass + srv.run() if __name__ == '__main__': diff --git a/cfg/seop_cfg.py b/cfg/seop_cfg.py new file mode 100644 index 0000000..8a8c024 --- /dev/null +++ b/cfg/seop_cfg.py @@ -0,0 +1,43 @@ +description = """ +3He system in Lab ... +""" +Node('mlz_seop', + description, + 'tcp://10767', +) + +Mod('cell', + 'frappy_mlz.seop.Cell', + 'interface module to the driver', + config_directory = '/home/jcns/daemon/config', +) + +Mod('afp', + 'frappy_mlz.seop.Afp', + 'controls the afp flip of the cell', + cell = 'cell' +) + +Mod('nmr', + 'frappy_mlz.seop.Nmr', + 'controls the ', + cell = 'cell' +) + +fitparams = [ + ('amplitude', 'V'), + ('T1', 's'), + ('T2', 's'), + ('b', ''), + ('frequency', 'Hz'), + ('phase', 'deg'), +] +for param, unit in fitparams: + Mod(f'nmr_{param.lower()}', + 'frappy_mlz.seop.FitParam', + f'fittet parameter {param} of NMR', + cell = 'cell', + value = Param(unit=unit), + sigma = Param(unit=unit), + param = param, + ) diff --git a/cfg/test_cfg.py b/cfg/test_cfg.py index 83cea44..dff379a 100644 --- a/cfg/test_cfg.py +++ b/cfg/test_cfg.py @@ -10,6 +10,22 @@ The needed fields are Equipment id (1st argument), description (this) 'tcp://10768', ) +Mod('attachtest', + 'frappy_demo.test.WithAtt', + 'test attached', + att = 'LN2', +) + +Mod('pinata', + 'frappy_demo.test.Pin', + 'scan test', +) + +Mod('recursive', + 'frappy_demo.test.RecPin', + 'scan test', +) + Mod('LN2', 'frappy_demo.test.LN2', 'random value between 0..100%', diff --git a/debian/changelog b/debian/changelog index 8166be0..307cfad 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,49 @@ +frappy-core (0.17.13) focal; urgency=medium + + [ Alexander Zaft ] + * add egg-info to gitignore + + [ Markus Zolliker ] + * GUI bugfix: use isChecked instead of checkState in BoolInput + * frappy_psi.mercury/triton: add control_off command + * frappy_psi.phytron: rename reset_error to clear_errors + * frappy.mixins.HasOutputModule + * frappy_psi.mercury: proper handling of control_active + * add a hook for reads to be done initially + * frappy_psi.triton: fix HeaterOutput.limit + * frappy_psi.magfield: bug fix + * frappy_psi.sea: bug fixes + + [ Alexander Zaft ] + * server: fix systemd variable scope + + -- Alexander Zaft Tue, 20 Jun 2023 14:38:00 +0200 + +frappy-core (0.17.12) focal; urgency=medium + + [ Alexander Zaft ] + * Warn about duplicate module definitions in a file + * Add influences property to parameters/commands + + [ Markus Zolliker ] + * frappy.client: dummy logger is missing 'exception' method + + [ Alexander Zaft ] + * Add SEOP He3-polarization device + * Typo in influences description + * seop: fix fitparam command + + [ Markus Zolliker ] + * silently catches error in systemd.daemon.notify + + [ Alexander Zaft ] + * io: add option to retry first ident request + * config: fix merge_modules + * io: followup fix for retry-first-ident + * entangle: fix tango guards for pytango 9.3 + + -- Alexander Zaft Tue, 13 Jun 2023 06:51:27 +0200 + frappy-core (0.17.11) focal; urgency=medium [ Alexander Zaft ] diff --git a/frappy/dynamic.py b/frappy/dynamic.py new file mode 100644 index 0000000..46b54f5 --- /dev/null +++ b/frappy/dynamic.py @@ -0,0 +1,41 @@ +# ***************************************************************************** +# +# 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: +# Alexander Zaft +# +# ***************************************************************************** + +from .core import Module + +class Pinata(Module): + """Base class for scanning conections and adding modules accordingly. + + Like a piñata. You poke it, and modules fall out. + + To use it, subclass it for your connection type and override the function + 'scanModules'. For each module you want to register, you should yield the + modules name and its config options. + The connection will then be scanned during server startup. + """ + export = False + + # POKE + def scanModules(self): + """yield (modname, options) for each module the Pinata should create. + Options has to include keys for class and the config for the module. + """ + raise NotImplementedError diff --git a/frappy/lib/__init__.py b/frappy/lib/__init__.py index 8b67cc2..8bbd22c 100644 --- a/frappy/lib/__init__.py +++ b/frappy/lib/__init__.py @@ -407,10 +407,15 @@ class UniqueObject: def merge_status(*args): """merge status - the status with biggest code wins - texts matching maximal code are joined with ', ' + for combining stati of different mixins + - the status with biggest code wins + - texts matching maximal code are joined with ', ' + - if texts already contain ', ', it is considered as composed by + individual texts and duplication is avoided. when commas are used + for other purposes, the behaviour might be surprising """ maxcode = max(a[0] for a in args) merged = [a[1] for a in args if a[0] == maxcode and a[1]] + # use dict instead of set for preserving order merged = {m: True for mm in merged for m in mm.split(', ')} return maxcode, ', '.join(merged) diff --git a/frappy/modules.py b/frappy/modules.py index 9f7a03e..fc57d1d 100644 --- a/frappy/modules.py +++ b/frappy/modules.py @@ -329,7 +329,6 @@ class Module(HasAccessibles): # reference to the dispatcher (used for sending async updates) DISPATCHER = None - attachedModules = None pollInfo = None triggerPoll = None # trigger event for polls. used on io modules and modules without io @@ -347,7 +346,9 @@ class Module(HasAccessibles): self.accessLock = threading.RLock() # for read_* / write_* methods self.updateLock = threading.RLock() # for announceUpdate self.polledModules = [] # modules polled by thread started in self.startModules + self.attachedModules = {} errors = [] + self._isinitialized = False # handle module properties # 1) make local copies of properties @@ -639,6 +640,13 @@ class Module(HasAccessibles): all parameters are polled once """ + def shutdownModule(self): + """called when the sever shuts down + + any cleanup-work should be performed here, like closing threads and + saving data. + """ + def doPoll(self): """polls important parameters like value and status @@ -932,10 +940,12 @@ class Attached(Property): def __get__(self, obj, owner): if obj is None: return self - if obj.attachedModules is None: - # return the name of the module (called from Server on startup) - return super().__get__(obj, owner) - # return the module (called after startup) + if self.name not in obj.attachedModules: + modobj = obj.DISPATCHER.get_module(super().__get__(obj, owner)) + if not isinstance(modobj, self.basecls): + raise ConfigError(f'attached module {self.name}={modobj.name!r} '\ + f'must inherit from {self.basecls.__qualname__!r}') + obj.attachedModules[self.name] = modobj return obj.attachedModules.get(self.name) # return None if not given def copy(self): diff --git a/frappy/params.py b/frappy/params.py index 8d33871..08b4485 100644 --- a/frappy/params.py +++ b/frappy/params.py @@ -540,7 +540,6 @@ class Limit(Parameter): 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) @@ -562,6 +561,7 @@ PREDEFINED_ACCESSIBLES = { 'unit': Parameter, # reserved name 'loglevel': Parameter, # reserved name 'mode': Parameter, # reserved name + 'ctrlpars': Parameter, # spec to be confirmed 'stop': Command, 'reset': Command, 'go': Command, diff --git a/frappy/protocol/dispatcher.py b/frappy/protocol/dispatcher.py index 53671e3..b7858fe 100644 --- a/frappy/protocol/dispatcher.py +++ b/frappy/protocol/dispatcher.py @@ -39,16 +39,18 @@ Interface to the modules: """ import threading +import traceback from collections import OrderedDict from time import time as currenttime from frappy.errors import NoSuchCommandError, NoSuchModuleError, \ - NoSuchParameterError, ProtocolError, ReadOnlyError + NoSuchParameterError, ProtocolError, ReadOnlyError, ConfigError from frappy.params import Parameter from frappy.protocol.messages import COMMANDREPLY, DESCRIPTIONREPLY, \ DISABLEEVENTSREPLY, ENABLEEVENTSREPLY, ERRORPREFIX, EVENTREPLY, \ HEARTBEATREPLY, IDENTREPLY, IDENTREQUEST, READREPLY, WRITEREPLY, \ LOGGING_REPLY, LOG_EVENT +from frappy.lib import get_class def make_update(modulename, pobj): @@ -84,6 +86,13 @@ class Dispatcher: self.name = name self.restart = srv.restart self.shutdown = srv.shutdown + # handle to server + self.srv = srv + # set of modules that failed creation + self.failed_modules = set() + # list of errors that occured during initialization + self.errors = [] + self.traceback_counter = 0 def broadcast_event(self, msg, reallyall=False): """broadcasts a msg to all active connections @@ -147,11 +156,92 @@ class Dispatcher: self._export.append(modulename) def get_module(self, modulename): + """ Returns a fully initialized module. Or None, if something went + wrong during instatiating/initializing the module.""" + modobj = self.get_module_instance(modulename) + if modobj is None: + return None + if modobj._isinitialized: + return modobj + + # also call earlyInit on the modules + self.log.info('initializing module %r', modulename) # TODO: change to debug + try: + modobj.earlyInit() + if not modobj.earlyInitDone: + self.errors.append(f'{modobj.earlyInit.__qualname__} was not called, probably missing super call') + modobj.initModule() + if not modobj.initModuleDone: + self.errors.append(f'{modobj.initModule.__qualname__} was not called, probably missing super call') + except Exception as e: + if self.traceback_counter == 0: + self.log.exception(traceback.format_exc()) + self.traceback_counter += 1 + self.errors.append(f'error initializing {modulename}: {e!r}') + modobj._isinitialized = True + self.log.info('initialized module %r', modulename) # TODO: change to debug + return modobj + + def get_module_instance(self, modulename): + """ Returns the module in its current initialization state or creates a + new uninitialized modle to return. + + When creating a new module, srv.module_config is accessed to get the + modules configuration. + """ if modulename in self._modules: return self._modules[modulename] if modulename in list(self._modules.values()): + # it's actually already the module object return modulename - raise NoSuchModuleError(f'Module {modulename!r} does not exist on this SEC-Node!') + # create module from srv.module_cfg, store and return + self.log.info('registering module %r', modulename) + + opts = self.srv.module_cfg.get(modulename, None) + if opts is None: + raise NoSuchModuleError(f'Module {modulename!r} does not exist on this SEC-Node!') + pymodule = None + try: # pylint: disable=no-else-return + classname = opts.pop('cls') + if isinstance(classname, str): + pymodule = classname.rpartition('.')[0] + if pymodule in self.failed_modules: + # creation has failed already once, do not try again + return None + cls = get_class(classname) + else: + pymodule = classname.__module__ + if pymodule in self.failed_modules: + # creation has failed already once, do not try again + return None + cls = classname + except Exception as e: + if str(e) == 'no such class': + self.errors.append(f'{classname} not found') + else: + self.failed_modules.add(pymodule) + if self.traceback_counter == 0: + self.log.exception(traceback.format_exc()) + self.traceback_counter += 1 + self.errors.append(f'error importing {classname}') + return None + else: + try: + modobj = cls(modulename, self.log.getChild(modulename), opts, self.srv) + except ConfigError as e: + self.errors.append(f'error creating module {modulename}:') + for errtxt in e.args[0] if isinstance(e.args[0], list) else [e.args[0]]: + self.errors.append(' ' + errtxt) + modobj = None + except Exception as e: + if self.traceback_counter == 0: + self.log.exception(traceback.format_exc()) + self.traceback_counter += 1 + self.errors.append(f'error creating {modulename}') + modobj = None + self.register_module(modobj, modulename, modobj.export) + self.srv.modules[modulename] = modobj # IS HERE THE CORRECT PLACE? + return modobj def remove_module(self, modulename_or_obj): moduleobj = self.get_module(modulename_or_obj) @@ -183,6 +273,7 @@ class Dispatcher: def get_descriptive_data(self, specifier): """returns a python object which upon serialisation results in the descriptive data""" + specifier = specifier or '' modules = {} result = {'modules': modules} for modulename in self._export: @@ -194,7 +285,7 @@ class Dispatcher: mod_desc.update(module.exportProperties()) mod_desc.pop('export', False) modules[modulename] = mod_desc - modname, _, pname = (specifier or '').partition(':') + modname, _, pname = specifier.partition(':') if modname in modules: # extension to SECoP standard: description of a single module result = modules[modname] if pname in result['accessibles']: # extension to SECoP standard: description of a single accessible diff --git a/frappy/server.py b/frappy/server.py index e846da0..5152728 100644 --- a/frappy/server.py +++ b/frappy/server.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # ***************************************************************************** # # This program is free software; you can redistribute it and/or modify it under @@ -24,16 +23,16 @@ """Define helpers""" import os +import signal import sys -import traceback from collections import OrderedDict -from frappy.errors import ConfigError, SECoPError -from frappy.lib import formatException, get_class, generalConfig +from frappy.config import load_config +from frappy.errors import ConfigError +from frappy.dynamic import Pinata +from frappy.lib import formatException, generalConfig, get_class, mkthread from frappy.lib.multievent import MultiEvent from frappy.params import PREDEFINED_ACCESSIBLES -from frappy.modules import Attached -from frappy.config import load_config try: from daemon import DaemonContext @@ -106,6 +105,12 @@ class Server: self._cfgfiles = cfgfiles self._pidfile = os.path.join(generalConfig.piddir, name + '.pid') + signal.signal(signal.SIGINT, self.signal_handler) + signal.signal(signal.SIGTERM, self.signal_handler) + + def signal_handler(self, _num, _frame): + if hasattr(self, 'interface') and self.interface: + self.shutdown() def start(self): if not DaemonContext: @@ -127,17 +132,18 @@ class Server: return f"{cls.__name__} class don't know how to handle option(s): {', '.join(options)}" def restart_hook(self): - pass + """Actions to be done on restart. May be overridden by a subclass.""" def run(self): + global systemd # pylint: disable=global-statement while self._restart: self._restart = False try: # TODO: make systemd notifications configurable - if systemd: # pylint: disable=used-before-assignment + if systemd: systemd.daemon.notify("STATUS=initializing") except Exception: - systemd = None # pylint: disable=redefined-outer-name + systemd = None try: self._processCfg() if self._testonly: @@ -156,13 +162,27 @@ class Server: self.log.info('startup done, handling transport messages') if systemd: systemd.daemon.notify("READY=1\nSTATUS=accepting requests") - self.interface.serve_forever() - self.interface.server_close() + t = mkthread(self.interface.serve_forever) + # we wait here on the thread finishing, which means we got a + # signal to shut down or an exception was raised + # TODO: get the exception (and re-raise?) + t.join() + self.interface = None # fine due to the semantics of 'with' + # server_close() called by 'with' + + self.log.info(f'stopped listenning, cleaning up' + f' {len(self.modules)} modules') + # if systemd: + # if self._restart: + # systemd.daemon.notify('RELOADING=1') + # else: + # systemd.daemon.notify('STOPPING=1') + for name in self._getSortedModules(): + self.modules[name].shutdownModule() if self._restart: self.restart_hook() - self.log.info('restart') - else: - self.log.info('shut down') + self.log.info('restarting') + self.log.info('shut down') def restart(self): if not self._restart: @@ -174,85 +194,57 @@ class Server: self.interface.shutdown() def _processCfg(self): + """Processes the module configuration. + + All modules specified in the config file and read recursively from + Pinata class Modules are instantiated, initialized and started by the + end of this function. + If there are errors that occur, they will be collected and emitted + together in the end. + """ errors = [] opts = dict(self.node_cfg) cls = get_class(opts.pop('cls')) - self.dispatcher = cls(opts.pop('name', self._cfgfiles), self.log.getChild('dispatcher'), opts, self) + self.dispatcher = cls(opts.pop('name', self._cfgfiles), + self.log.getChild('dispatcher'), opts, self) if opts: - errors.append(self.unknown_options(cls, opts)) + self.dispatcher.errors.append(self.unknown_options(cls, opts)) self.modules = OrderedDict() - failure_traceback = None # traceback for the first error - failed = set() # python modules failed to load - self.lastError = None - for modname, options in self.module_cfg.items(): - opts = dict(options) - pymodule = None - try: - classname = opts.pop('cls') - pymodule = classname.rpartition('.')[0] - if pymodule in failed: - continue - cls = get_class(classname) - except Exception as e: - if str(e) == 'no such class': - errors.append(f'{classname} not found') - else: - failed.add(pymodule) - if failure_traceback is None: - failure_traceback = traceback.format_exc() - errors.append(f'error importing {classname}') - else: - try: - modobj = cls(modname, self.log.getChild(modname), opts, self) - self.modules[modname] = modobj - except ConfigError as e: - errors.append(f'error creating module {modname}:') - for errtxt in e.args[0] if isinstance(e.args[0], list) else [e.args[0]]: - errors.append(' ' + errtxt) - except Exception: - if failure_traceback is None: - failure_traceback = traceback.format_exc() - errors.append(f'error creating {modname}') - missing_super = set() - # all objs created, now start them up and interconnect - for modname, modobj in self.modules.items(): - self.log.info('registering module %r', modname) - self.dispatcher.register_module(modobj, modname, modobj.export) - # also call earlyInit on the modules - modobj.earlyInit() - if not modobj.earlyInitDone: - missing_super.add(f'{modobj.earlyInit.__qualname__} was not called, probably missing super call') + # create and initialize modules + todos = list(self.module_cfg.items()) + while todos: + modname, options = todos.pop(0) + if modname in self.modules: + # already created by Dispatcher (via Attached) + continue + # For Pinata modules: we need to access this in Dispatcher.get_module + self.module_cfg[modname] = dict(options) + modobj = self.dispatcher.get_module_instance(modname) # lazy + if modobj is None: + self.log.debug('Module %s returned None', modname) + continue + self.modules[modname] = modobj + if isinstance(modobj, Pinata): + # scan for dynamic devices + pinata = self.dispatcher.get_module(modname) + pinata_modules = list(pinata.scanModules()) + for name, _cfg in pinata_modules: + if name in self.module_cfg: + self.log.error('Module %s, from pinata %s, already' + ' exists in config file!', name, modname) + self.log.info('Pinata %s found %d modules', modname, len(pinata_modules)) + todos.extend(pinata_modules) - # handle attached modules - for modname, modobj in self.modules.items(): - attached_modules = {} - for propname, propobj in modobj.propertyDict.items(): - if isinstance(propobj, Attached): - try: - attname = getattr(modobj, propname) - if attname: # attached module specified in cfg file - attobj = self.dispatcher.get_module(attname) - if isinstance(attobj, propobj.basecls): - attached_modules[propname] = attobj - else: - errors.append(f'attached module {propname}={attname!r} '\ - f'must inherit from {propobj.basecls.__qualname__!r}') - except SECoPError as e: - errors.append(f'module {modname}, attached {propname}: {str(e)}') - modobj.attachedModules = attached_modules + # initialize all modules by getting them with Dispatcher.get_module, + # which is done in the get_descriptive data + # TODO: caching, to not make this extra work + self.dispatcher.get_descriptive_data('') + # =========== All modules are initialized =========== - # call init on each module after registering all - for modname, modobj in self.modules.items(): - try: - modobj.initModule() - if not modobj.initModuleDone: - missing_super.add(f'{modobj.initModule.__qualname__} was not called, probably missing super call') - except Exception as e: - if failure_traceback is None: - failure_traceback = traceback.format_exc() - errors.append(f'error initializing {modname}: {e!r}') + # all errors from initialization process + errors = self.dispatcher.errors if not self._testonly: start_events = MultiEvent(default_timeout=30) @@ -261,8 +253,7 @@ class Server: start_events.name = f'module {modname}' modobj.startModule(start_events) if not modobj.startModuleDone: - missing_super.add(f'{modobj.startModule.__qualname__} was not called, probably missing super call') - errors.extend(missing_super) + errors.append(f'{modobj.startModule.__qualname__} was not called, probably missing super call') if errors: for errtxt in errors: @@ -271,8 +262,6 @@ class Server: # print a list of config errors to stderr sys.stderr.write('\n'.join(errors)) sys.stderr.write('\n') - if failure_traceback: - sys.stderr.write(failure_traceback) sys.exit(1) if self._testonly: @@ -299,3 +288,41 @@ class Server: # history_path = os.environ.get('ALTERNATIVE_HISTORY') # if history_path: # from frappy_.historywriter import ... etc. + + def _getSortedModules(self): + """Sort modules topologically by inverse dependency. + + Example: if there is an IO device A and module B depends on it, then + the result will be [B, A]. + Right now, if the dependency graph is not a DAG, we give up and return + the unvisited nodes to be dismantled at the end. + Taken from Introduction to Algorithms [CLRS]. + """ + def go(name): + if name in done: # visiting a node + return True + if name in visited: + visited.add(name) + return False # cycle in dependencies -> fail + visited.add(name) + if name in unmarked: + unmarked.remove(name) + for module in self.modules[name].attachedModules.values(): + res = go(module.name) + if not res: + return False + visited.remove(name) + done.add(name) + l.append(name) + return True + + unmarked = set(self.modules.keys()) # unvisited nodes + visited = set() # visited in DFS, but not completed + done = set() + l = [] # list of sorted modules + + while unmarked: + if not go(unmarked.pop()): + self.log.error('cyclical dependency between modules!') + return l[::-1] + list(visited) + list(unmarked) + return l[::-1] diff --git a/frappy/structparam.py b/frappy/structparam.py new file mode 100644 index 0000000..2534dda --- /dev/null +++ b/frappy/structparam.py @@ -0,0 +1,164 @@ +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** +"""convenience class to create a struct Parameter together with indivdual params + +Usage: + + class Controller(Drivable): + + ... + + ctrlpars = StructParam('ctrlpars struct', [ + ('pid_p', 'p', Parameter('control parameter p', FloatRange())), + ('pid_i', 'i', Parameter('control parameter i', FloatRange())), + ('pid_d', 'd', Parameter('control parameter d', FloatRange())), + ], readonly=False) + + ... + + then implement either read_ctrlpars and write_ctrlpars or + read_pid_p, read_pid_i, read_pid_d, write_pid_p, write_pid_i and write_pid_d + + the methods not implemented will be created automatically +""" + +from frappy.core import Parameter, Property +from frappy.datatypes import BoolType, DataType, StructOf, ValueType +from frappy.errors import ProgrammingError + + +class StructParam(Parameter): + """create a struct parameter together with individual parameters + + in addition to normal Parameter arguments: + + :param paramdict: dict of Parameter(...) + :param prefix_or_map: either a prefix for the parameter name to add to the member name + or a dict or + """ + # use properties, as simple attributes are not considered on copy() + paramdict = Property('dict of Parameter(...)', ValueType()) + hasStructRW = Property('has a read_ or write_ method', + BoolType(), default=False) + + insideRW = 0 # counter for avoiding multiple superfluous updates + + def __init__(self, description=None, paramdict=None, prefix_or_map='', *, datatype=None, readonly=False, **kwds): + if isinstance(paramdict, DataType): + raise ProgrammingError('second argument must be a dict of Param') + if datatype is None and paramdict is not None: # omit the following on Parameter.copy() + if isinstance(prefix_or_map, str): + prefix_or_map = {m: prefix_or_map + m for m in paramdict} + for membername, param in paramdict.items(): + param.name = prefix_or_map[membername] + datatype = StructOf(**{m: p.datatype for m, p in paramdict.items()}) + kwds['influences'] = [p.name for p in paramdict.values()] + self.updateEnable = {} + super().__init__(description, datatype, paramdict=paramdict, readonly=readonly, **kwds) + + def __set_name__(self, owner, name): + # names of access methods of structed param (e.g. ctrlpars) + struct_read_name = f'read_{name}' # e.g. 'read_ctrlpars' + struct_write_name = f'write_{name}' # e.h. 'write_ctrlpars' + self.hasStructRW = hasattr(owner, struct_read_name) or hasattr(owner, struct_write_name) + + for membername, param in self.paramdict.items(): + pname = param.name + changes = { + 'readonly': self.readonly, + 'influences': set(param.influences) | {name}, + } + param.ownProperties.update(changes) + param.init(changes) + setattr(owner, pname, param) + param.__set_name__(owner, param.name) + + if self.hasStructRW: + rname = f'read_{pname}' + + if not hasattr(owner, rname): + def rfunc(self, membername=membername, struct_read_name=struct_read_name): + return getattr(self, struct_read_name)()[membername] + + rfunc.poll = False # read_ is polled only + setattr(owner, rname, rfunc) + + if not self.readonly: + wname = f'write_{pname}' + if not hasattr(owner, wname): + def wfunc(self, value, membername=membername, + name=name, rname=rname, struct_write_name=struct_write_name): + valuedict = dict(getattr(self, name)) + valuedict[membername] = value + getattr(self, struct_write_name)(valuedict) + return getattr(self, rname)() + + setattr(owner, wname, wfunc) + + if not self.hasStructRW: + if not hasattr(owner, struct_read_name): + def struct_read_func(self, name=name, flist=tuple( + (m, f'read_{p.name}') for m, p in self.paramdict.items())): + pobj = self.parameters[name] + # disable updates generated from the callbacks of individual params + pobj.insideRW += 1 # guarded by self.accessLock + try: + return {m: getattr(self, f)() for m, f in flist} + finally: + pobj.insideRW -= 1 + + setattr(owner, struct_read_name, struct_read_func) + + if not (self.readonly or hasattr(owner, struct_write_name)): + + def struct_write_func(self, value, name=name, funclist=tuple( + (m, f'write_{p.name}') for m, p in self.paramdict.items())): + pobj = self.parameters[name] + pobj.insideRW += 1 # guarded by self.accessLock + try: + return {m: getattr(self, f)(value[m]) for m, f in funclist} + finally: + pobj.insideRW -= 1 + + setattr(owner, struct_write_name, struct_write_func) + + super().__set_name__(owner, name) + + def finish(self, modobj=None): + """register callbacks for consistency""" + super().finish(modobj) + if modobj: + + if self.hasStructRW: + def cb(value, modobj=modobj, structparam=self): + for membername, param in structparam.paramdict.items(): + setattr(modobj, param.name, value[membername]) + + modobj.valueCallbacks[self.name].append(cb) + else: + for membername, param in self.paramdict.items(): + def cb(value, modobj=modobj, structparam=self, membername=membername): + if not structparam.insideRW: + prev = dict(getattr(modobj, structparam.name)) + prev[membername] = value + setattr(modobj, structparam.name, prev) + + modobj.valueCallbacks[param.name].append(cb) diff --git a/frappy/version.py b/frappy/version.py index 3b87306..3fc3880 100644 --- a/frappy/version.py +++ b/frappy/version.py @@ -70,12 +70,11 @@ def get_version(abbrev=4): if git_version != release_version: write_release_version(git_version) return git_version - elif release_version: + if release_version: return release_version - else: - raise ValueError('Cannot find a version number - make sure that ' - 'git is installed or a RELEASE-VERSION file is ' - 'present!') + raise ValueError('Cannot find a version number - make sure that ' + 'git is installed or a RELEASE-VERSION file is ' + 'present!') if __name__ == "__main__": diff --git a/frappy_demo/cryo.py b/frappy_demo/cryo.py index c996422..b357cc4 100644 --- a/frappy_demo/cryo.py +++ b/frappy_demo/cryo.py @@ -354,7 +354,7 @@ class Cryostat(CryoBase): timestamp = t self.read_value() - def shutdown(self): + def shutdownModule(self): # should be called from server when the server is stopped self._stopflag = True if self._thread and self._thread.is_alive(): diff --git a/frappy_demo/test.py b/frappy_demo/test.py index b9ecac2..9f494a8 100644 --- a/frappy_demo/test.py +++ b/frappy_demo/test.py @@ -24,10 +24,33 @@ import random from frappy.datatypes import FloatRange, StringType, ValueType, TupleOf, StructOf, ArrayOf -from frappy.modules import Communicator, Drivable, Parameter, Property, Readable, Module +from frappy.modules import Communicator, Drivable, Parameter, Property, Readable, Module, Attached from frappy.params import Command +from frappy.dynamic import Pinata from frappy.errors import RangeError +class Pin(Pinata): + def scanModules(self): + yield ('pin_a', {'cls': LN2, 'description':'hi'}) + yield ('pin_b', {'cls': LN2, 'description':'hi'}) + +class RecPin(Pinata): + def scanModules(self): + yield ('rec_a', {'cls': RecPinInner, 'description':'hi'}) + yield ('rec_b', {'cls': RecPinInner, 'description':'hi'})#, 'idx':'_2'}) + +class RecPinInner(Pinata): + idx = Property('', StringType(), default='') + def scanModules(self): + yield ('pin_pin_a' + self.idx, {'cls': Mapped, 'description':'recursive!', 'choices':['A', 'B']}) + yield ('pin_pin_b' + self.idx, {'cls': Mapped, 'description':'recursive!', 'choices':['A', 'B']}) + + +class WithAtt(Readable): + att = Attached() + + def read_value(self): + return self.att.read_value() class LN2(Readable): """Just a readable. diff --git a/frappy_mlz/seop.py b/frappy_mlz/seop.py new file mode 100644 index 0000000..c193806 --- /dev/null +++ b/frappy_mlz/seop.py @@ -0,0 +1,234 @@ +# -*- 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: +# Georg Brandl +# Alexander Zaft +# +# ***************************************************************************** + +"""Adapter to the existing SEOP 3He spin filter system daemon.""" + +from os import path + +# eventually he3control +from he3d import he3cell # pylint: disable=import-error + +from frappy.core import Attached +from frappy.datatypes import ArrayOf, FloatRange, IntRange, StatusType, \ + StringType, TupleOf +from frappy.errors import CommandRunningError +from frappy.modules import Command, Drivable, Module, Parameter, Property, \ + Readable +from frappy.rwhandler import CommonReadHandler + +integral = IntRange() +floating = FloatRange() +string = StringType() + +# Configuration is kept in YAML files to stay compatible to the +# traditional 3He daemon, for now. + + +class Cell(Module): + """ Dummy module for creating He3Cell object in order for other modules to talk to the hardware. + Only deals with the config, and rotating the paramlog. + """ + config_directory = Property( + 'Directory for the YAML config files', datatype=string) + + def initModule(self): + super().initModule() + self.cell = he3cell.He3_cell( + path.join(self.config_directory, 'cell.yml')) + + # Commands + @Command(result=string) + def raw_config_file(self): + """return unparsed contents of yaml file""" + with open(self.cell._He3_cell__cfg_filename, 'r', encoding='utf-8') as f: + return str(f.read()) + + @Command(string, result=string) + def cfg_get(self, identifier): + """Get a configuration value.""" + return str(self.cell.cfg_get(identifier)) + + @Command((string, string), result=string) + def cfg_set(self, identifier, value): + """Set a configuration value.""" + try: + value = int(value) + except ValueError: + try: + value = float(value) + except ValueError: + pass + # The type is lost during transmission. + # Check type so the value to be set has the same type and + # is not eg. a string where an int would be needed in the config key. + oldty = type(self.cell.cfg_get(identifier)) + if oldty is not type(value): + raise ValueError('Type of value to be set does not match the ' + 'value in the configuration!') + return str(self.cell.cfg_set(identifier, value)) + + @Command() + def nmr_paramlog_rotate(self): + """resets fitting and switches to a new logfile""" + self.cell.nmr_paramlog_rotate() + + +class Afp(Readable): + """Polarisation state of the SEOP waveplates""" + + value = Parameter('Current polarisation state of the SEOP waveplates', IntRange(0, 1)) + + cell = Attached(Cell) + + def read_value(self): + return self.cell.cell.afp_state_get() + + # Commands + @Command(description='Flip polarization of SEOP waveplates') + def afp_flip(self): + self.cell.cell.afp_flip_do() + self.read_value() + + +class Nmr(Readable): + Status = Drivable.Status + status = Parameter(datatype=StatusType(Drivable.Status)) + value = Parameter('Timestamp of last NMR', string) + cell = Attached(Cell) + + def initModule(self): + super().initModule() + self.interval = 0 + + def read_value(self): + return str(self.cell.cell.nmr_timestamp_get()) + + def read_status(self): + cellstate = self.cell.cell.nmr_state_get() + + if self.cell.cell.nmr_background_check(): + status = self.Status.BUSY, 'running every %d seconds' % self.interval + else: + status = self.Status.IDLE, 'not running' + + # TODO: what do we do here with None and -1? + # -> None basically indicates that the fit for the parameters did not converge + if cellstate is None: + return self.Status.IDLE, f'returned None, {status[1]}' + if cellstate in (0, 1): + return status[0], f'nmr cellstate {cellstate}, {status[1]}' + if cellstate == -1: + return self.Status.WARN, f'got error from cell, {status[1]}' + return self.Status.ERROR, 'Unrecognized cellstate!' + + # Commands + @Command() + def nmr_do(self): + """Triggers the NMR to run""" + self.cell.cell.nmr_do() + self.read_status() + + @Command() + def bgstart(self): + """Start background NMR""" + if self.isBusy(): + raise CommandRunningError('backgroundNMR is already running') + interval = self.cell.cell.cfg_get('tasks/nmr/background/interval') + self.interval = interval + self.cell.cell.nmr_background_start(interval) + self.read_status() + + @Command() + def bgstop(self): + """Stop background NMR""" + self.cell.cell.nmr_background_stop() + self.read_status() + + # Commands to get large datasets we do not want directly in the NICOS cache + @Command(result=TupleOf(ArrayOf(floating, maxlen=100000), + ArrayOf(floating, maxlen=100000))) + def get_processed_nmr(self): + """Get data for processed signal.""" + val= self.cell.cell.nmr_processed_get() + return (val['xval'], val['yval']) + + @Command(result=TupleOf(ArrayOf(floating, maxlen=100000), + ArrayOf(floating, maxlen=100000))) + def get_raw_nmr(self): + """Get raw signal data.""" + val = self.cell.cell.nmr_raw_get() + return (val['xval'], val['yval']) + + @Command(result=TupleOf(ArrayOf(floating, maxlen=100000), + ArrayOf(floating, maxlen=100000))) + def get_raw_spectrum(self): + """Get the raw spectrum.""" + val = self.cell.cell.nmr_raw_spectrum_get() + y = val['yval'][:len(val['xval'])] + return (val['xval'], y) + + @Command(result=TupleOf(ArrayOf(floating, maxlen=100000), + ArrayOf(floating, maxlen=100000))) + def get_processed_spectrum(self): + """Get the processed spectrum.""" + val = self.cell.cell.nmr_processed_spectrum_get() + x = val['xval'][:len(val['yval'])] + return (x, val['yval']) + + @Command(result=TupleOf(ArrayOf(string, maxlen=100), + ArrayOf(floating, maxlen=100))) + def get_amplitude(self): + """Last 20 amplitude datapoints.""" + rv = self.cell.cell.nmr_paramlog_get('amplitude', 20) + x = [ str(timestamp) for timestamp in rv['xval']] + return (x,rv['yval']) + + @Command(result=TupleOf(ArrayOf(string, maxlen=100), + ArrayOf(floating, maxlen=100))) + def get_phase(self): + """Last 20 phase datapoints.""" + val = self.cell.cell.nmr_paramlog_get('phase', 20) + return ([str(timestamp) for timestamp in val['xval']], val['yval']) + + +class FitParam(Readable): + value = Parameter('fitted value', unit='$', default=0.0) + sigma = Parameter('variance of the fitted value', FloatRange(), default=0.0) + param = Property('the parameter that should be accesssed', + StringType(), export=False) + + cell = Attached(Cell) + + @CommonReadHandler(['value', 'sigma']) + def read_amplitude(self): + ret = self.cell.cell.nmr_param_get(self.param) + self.value = ret['value'] + self.sigma = ret['sigma'] + + # Commands + @Command(integral, result=TupleOf(ArrayOf(string), + ArrayOf(floating))) + def nmr_paramlog_get(self, n): + """returns the log of the last 'n' values for this parameter""" + val = self.cell.cell.nmr_paramlog_get(self.param, n) + return ([str(timestamp) for timestamp in val['xval']], val['yval']) diff --git a/frappy_psi/thermofisher.py b/frappy_psi/thermofisher.py index 589dfe0..f160e06 100644 --- a/frappy_psi/thermofisher.py +++ b/frappy_psi/thermofisher.py @@ -1,5 +1,3 @@ -#!/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 @@ -17,64 +15,13 @@ # # Module authors: # Oksana Shliakhtun +# Markus Zolliker # ***************************************************************************** -""" RUFS Command: Description of Bits +"""bath thermostat Thermo Scientific™ ARCTIC A10 Refrigerated Circulators""" - ====== ======================================================== ============================================== - Value Description - ====== ======================================================== ============================================== - V1 - B6: warning, rtd1 (internal temp. sensor) is shorted - B0 --> 1 - B7: warning, rtd1 is open - B1 --> 2 - V2 - B0: error, HTC (high temperature cutout) fault B2 --> 4 - - B1: error, high RA (refrigeration) temperature fault B3 --> 8 - - V3 B4 --> 16 - B0: warning, low level in the bath - B5 --> 32 - B1: warning, low temperature - B6 --> 64 - B2: warning, high temperature - B7 --> 128 - B3: error, low level in the bath - - B4: error, low temperature fault - - B5: error, high temperature fault - - B6: error, low temperature fixed* fault - - B7: error, high temperature fixed** fault - - V4 - B3: idle, circulator** is running - - B5: error, circulator** fault - - V5 - B0: error, pump speed fault - - B1: error, motor overloaded - - B2: error, high pressure cutout - - B3: idle, maximum cooling - - B4: idle, cooling - - B5: idle, maximum heating - - B6: idle, heating - ====== ======================================================== ============================================== - -""" - -from frappy.core import StringIO, Parameter, HasIO, \ +from frappy.core import Command, StringIO, Parameter, HasIO, \ Drivable, FloatRange, IDLE, BUSY, ERROR, WARN, BoolType +from frappy.structparam import StructParam from frappy_psi.convergence import HasConvergence @@ -85,17 +32,17 @@ class ThermFishIO(StringIO): class TemperatureLoopA10(HasConvergence, HasIO, Drivable): ioClass = ThermFishIO - value = Parameter('internal temperature', unit='degC') value = Parameter('temperature', unit='degC') target = Parameter('setpoint/target', datatype=FloatRange, unit='degC', default=0) - circ_on = Parameter('is circulation running', BoolType(), readonly=False, default=False) - # pids - p_heat = Parameter('proportional heat parameter', FloatRange(), readonly=False) - i_heat = Parameter('integral heat parameter', FloatRange(), readonly=False) - d_heat = Parameter('derivative heat parameter', FloatRange(), readonly=False) - p_cool = Parameter('proportional cool parameter', FloatRange(), readonly=False) - i_cool = Parameter('integral cool parameter', FloatRange(), readonly=False) - d_cool = Parameter('derivative cool parameter', FloatRange(), readonly=False) + control_active = Parameter('circilation and control is on', BoolType(), default=False) + ctrlpars = StructParam('control parameters struct', dict( + p_heat = Parameter('proportional heat parameter', FloatRange()), + i_heat = Parameter('integral heat parameter', FloatRange()), + d_heat = Parameter('derivative heat parameter', FloatRange()), + p_cool = Parameter('proportional cool parameter', FloatRange()), + i_cool = Parameter('integral cool parameter', FloatRange()), + d_cool = Parameter('derivative cool parameter', FloatRange()), + ), readonly=False) status_messages = [ (ERROR, 'high tempr. cutout fault', 2, 0), @@ -122,20 +69,22 @@ class TemperatureLoopA10(HasConvergence, HasIO, Drivable): ] def get_par(self, cmd): - """ - All the reading commands starts with 'R', in the source code all the commands are written without 'R' (except - 'RUFS').The result of a reading command is a value in the format '20C', without spaces. + """get parameter and convert to float - :param cmd: any hardware command + :param cmd: hardware command without the leading 'R' - :return: 'R'+cmd + :return: result converted to float """ new_cmd = 'R' + cmd - reply = self.communicate(new_cmd) - if any(unit.isalpha() for unit in reply): - reply = ''.join(unit for unit in reply if not unit.isalpha()) + reply = self.communicate(new_cmd).strip() + while reply[-1].isalpha(): + reply = reply[:-1] return float(reply) + def set_par(self, cmd, value): + self.communicate(f'S{cmd} {value}') + return self.get_par(cmd) + def read_value(self): """ Reading internal temperature sensor value. @@ -143,6 +92,34 @@ class TemperatureLoopA10(HasConvergence, HasIO, Drivable): return self.get_par('T') def read_status(self): + """ convert from RUFS Command: Description of Bits + + ====== ======================================================== =============== + Value Description + ====== ======================================================== =============== + V1 B6: warning, rtd1 (internal temp. sensor) is shorted B0 --> 1 + B7: warning, rtd1 is open B1 --> 2 + V2 B0: error, HTC (high temperature cutout) fault B2 --> 4 + B1: error, high RA (refrigeration) temperature fault B3 --> 8 + V3 B0: warning, low level in the bath B5 --> 32 + B1: warning, low temperature B6 --> 64 + B2: warning, high temperature B7 --> 128 + B3: error, low level in the bath + B4: error, low temperature fault + B5: error, high temperature fault + B6: error, low temperature fixed* fault + B7: error, high temperature fixed** fault + V4 B3: idle, circulator** is running + B5: error, circulator** fault + V5 B0: error, pump speed fault + B1: error, motor overloaded + B2: error, high pressure cutout + B3: idle, maximum cooling + B4: idle, cooling + B5: idle, maximum heating + B6: idle, heating + ====== ======================================================== =============== + """ result_str = self.communicate('RUFS') # read unit fault status values_str = result_str.strip().split() values_int = [int(val) for val in values_str] @@ -157,72 +134,55 @@ class TemperatureLoopA10(HasConvergence, HasIO, Drivable): return status_type, status_msg return WARN, 'circulation off' - def read_circ_on(self): - return self.communicate('RO') + def read_control_active(self): + return int(self.get_par('O')) - def write_circ_on(self, circ_on): - circ_on_str = '1' if circ_on else '0' - self.communicate(f'SO {circ_on_str}') - return self.read_circ_on() + @Command + def control_off(self): + """switch control and circulation off""" + self.control_active = self.set_par('O', 0) def read_target(self): return self.get_par('S') def write_target(self, target): - """ - :param target: here, it serves as an equivalent to a setpoint. - """ - self.write_circ_on('1') + self.control_active = self.set_par('O', 1) self.communicate(f'SS {target}') self.convergence_start() return target - ## heat PID def read_p_heat(self): - p_heat = self.get_par('PH') - return float(p_heat) + return self.get_par('PH') - def write_p_heat(self, p_heat): - self.communicate(f'SPH {p_heat}') - return p_heat + def write_p_heat(self, value): + return self.set_par('PH', value) def read_i_heat(self): - i_heat = self.get_par('IH') - return float(i_heat) + return self.get_par('IH') - def write_i_heat(self, i_heat): - self.communicate(f'SIH {i_heat}') - return i_heat + def write_i_heat(self, value): + return self.set_par('IH', value) def read_d_heat(self): - d_heat = self.get_par('DH') - return float(d_heat) + return self.get_par('DH') - def write_d_heat(self, d_heat): - self.communicate(f'SDH {d_heat}') - return d_heat + def write_d_heat(self, value): + return self.set_par('DH', value) - ## cool PID def read_p_cool(self): - p_cool = self.get_par('PC') - return float(p_cool) + return self.get_par('PC') - def write_p_cool(self, p_cool): - self.communicate(f'SPC {p_cool}') - return p_cool + def write_p_cool(self, value): + return self.set_par('PC', value) def read_i_cool(self): - i_cool = self.get_par('IC') - return float(i_cool) + return self.get_par('IC') - def write_i_cool(self, i_cool): - self.communicate(f'SIC {i_cool}') - return i_cool + def write_i_cool(self, value): + return self.set_par('IC', value) def read_d_cool(self): - d_cool = self.get_par('DC') - return float(d_cool) + return self.get_par('DC') - def write_d_cool(self, d_cool): - self.communicate(f'SDC {d_cool}') - return d_cool + def write_d_cool(self, value): + return self.set_par('DC', value) diff --git a/test/test_attach.py b/test/test_attach.py index ebb6a47..6b6c251 100644 --- a/test/test_attach.py +++ b/test/test_attach.py @@ -75,6 +75,4 @@ def test_attach(): assert m.propertyValues['att'] == 'a' srv.dispatcher.register_module(a, 'a') srv.dispatcher.register_module(m, 'm') - assert m.att == 'a' - m.attachedModules = {'att': a} assert m.att == a diff --git a/test/test_config.py b/test/test_config.py new file mode 100644 index 0000000..5095a76 --- /dev/null +++ b/test/test_config.py @@ -0,0 +1,141 @@ +# ***************************************************************************** +# +# 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: +# Alexander Zaft +# +# ***************************************************************************** + +# false positive with fixtures +# pylint: disable=redefined-outer-name +import pytest + +from frappy.config import Collector, Config, Mod, NodeCollector, load_config, \ + process_file, to_config_path +from frappy.errors import ConfigError +from frappy.lib import generalConfig + + +class LoggerStub: + def debug(self, fmt, *args): + pass + info = warning = exception = error = debug + handlers = [] + + +@pytest.fixture +def log(): + return LoggerStub() + + +PY_FILE = """Node('foonode', 'fodesc', 'fooface') +Mod('foo', 'frappy.modules.Readable', 'description', value=5) +Mod('bar', 'frappy.modules.Readable', 'about me', export=False) +Mod('baz', 'frappy.modules.Readable', 'things', value=Param(3, unit='BAR')) +""" + + +# fixture file system, TODO: make a bit nicer? +@pytest.fixture +def direc(tmp_path_factory): + d = tmp_path_factory.mktemp('cfgdir') + a = d / 'a' + b = d / 'b' + a.mkdir() + b.mkdir() + f = a / 'config_cfg.py' + pyfile = a / 'pyfile_cfg.py' + ff = b / 'test_cfg.py' + fff = b / 'alsoworks.py' + f.touch() + ff.touch() + fff.touch() + pyfile.write_text(PY_FILE) + generalConfig.testinit(confdir=f'{a}:{b}', piddir=str(d)) + return d + + +files = [('config', 'a/config_cfg.py'), + ('config_cfg', 'a/config_cfg.py'), + ('config_cfg.py', 'a/config_cfg.py'), + ('test', 'b/test_cfg.py'), + ('test_cfg', 'b/test_cfg.py'), + ('test_cfg.py', 'b/test_cfg.py'), + ('alsoworks', 'b/alsoworks.py'), + ('alsoworks.py', 'b/alsoworks.py'), + ] + + +@pytest.mark.parametrize('file, res', files) +def test_to_cfg_path(log, direc, file, res): + assert to_config_path(file, log).endswith(res) + + +def test_cfg_not_existing(direc, log): + with pytest.raises(ConfigError): + to_config_path('idonotexist', log) + + +def collector_helper(node, mods): + n = NodeCollector() + n.add(*node) + m = Collector(Mod) + m.list = [Mod(module, '', '') for module in mods] + return n, m + + +configs = [ + (['n1', 'desc', 'iface'], ['foo', 'bar', 'baz'], ['n2', 'foo', 'bar'], + ['foo', 'more', 'other'], ['n1', 'iface', 5, {'foo'}]), + (['n1', 'desc', 'iface'], ['foo', 'bar', 'baz'], ['n2', 'foo', 'bar'], + ['different', 'more', 'other'], ['n1', 'iface', 6, set()]), +] + + +@pytest.mark.parametrize('n1, m1, n2, m2, res', configs) +def test_merge(n1, m1, n2, m2, res): + name, iface, num_mods, ambig = res + c1 = Config(*collector_helper(n1, m1)) + c2 = Config(*collector_helper(n2, m2)) + c1.merge_modules(c2) + assert c1['node']['equipment_id'] == name + assert c1['node']['interface'] == iface + assert len(c1.module_names) == num_mods + assert c1.ambiguous == ambig + + +def do_asserts(ret): + assert len(ret.module_names) == 3 + assert set(ret.module_names) == set(['foo', 'bar', 'baz']) + assert ret['node']['equipment_id'] == 'foonode' + assert ret['node']['interface'] == 'fooface' + assert ret['foo'] == {'cls': 'frappy.modules.Readable', + 'description': 'description', 'value': {'value': 5}} + assert ret['bar'] == {'cls': 'frappy.modules.Readable', + 'description': 'about me', 'export': {'value': False}} + assert ret['baz'] == {'cls': 'frappy.modules.Readable', + 'description': 'things', + 'value': {'value': 3, 'unit': 'BAR'}} + + +def test_process_file(direc, log): + ret = process_file(str(direc / 'a' / 'pyfile_cfg.py'), log) + do_asserts(ret) + + +def test_full(direc, log): + ret = load_config('pyfile_cfg.py', log) + do_asserts(ret) diff --git a/test/test_ctrlpars.py b/test/test_ctrlpars.py new file mode 100644 index 0000000..dff02a3 --- /dev/null +++ b/test/test_ctrlpars.py @@ -0,0 +1,132 @@ +# ***************************************************************************** +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Module authors: +# Markus Zolliker +# +# ***************************************************************************** +"""test frappy.mixins.HasCtrlPars""" + + +from test.test_modules import LoggerStub, ServerStub +from frappy.core import FloatRange, Module, Parameter +from frappy.structparam import StructParam + + +def test_with_read_ctrlpars(): + class Mod(Module): + ctrlpars = StructParam('ctrlpar struct', dict( + p = Parameter('control parameter p', FloatRange()), + i = Parameter('control parameter i', FloatRange()), + d = Parameter('control parameter d', FloatRange()), + ), 'pid_', readonly=False) + + def read_ctrlpars(self): + return self._ctrlpars + + def write_ctrlpars(self, value): + self._ctrlpars = value + return self.read_ctrlpars() + + logger = LoggerStub() + updates = {} + srv = ServerStub(updates) + + ms = Mod('ms', logger, {'description':''}, srv) + + value = {'p': 1, 'i': 2, 'd': 3} + assert ms.write_ctrlpars(value) == value + assert ms.read_ctrlpars() == value + assert ms.read_pid_p() == 1 + assert ms.read_pid_i() == 2 + assert ms.read_pid_d() == 3 + assert ms.write_pid_i(5) == 5 + assert ms.write_pid_d(0) == 0 + assert ms.read_ctrlpars() == {'p': 1, 'i': 5, 'd': 0} + assert set(Mod.ctrlpars.influences) == {'pid_p', 'pid_i', 'pid_d'} + assert Mod.pid_p.influences == ('ctrlpars',) + assert Mod.pid_i.influences == ('ctrlpars',) + assert Mod.pid_d.influences == ('ctrlpars',) + + +def test_without_read_ctrlpars(): + class Mod(Module): + ctrlpars = StructParam('ctrlpar struct', dict( + p = Parameter('control parameter p', FloatRange()), + i = Parameter('control parameter i', FloatRange()), + d = Parameter('control parameter d', FloatRange()), + ), readonly=False) + + _pid_p = 0 + _pid_i = 0 + + def read_p(self): + return self._pid_p + + def write_p(self, value): + self._pid_p = value + return self.read_p() + + def read_i(self): + return self._pid_i + + def write_i(self, value): + self._pid_i = value + return self.read_i() + + logger = LoggerStub() + updates = {} + srv = ServerStub(updates) + + ms = Mod('ms', logger, {'description': ''}, srv) + + value = {'p': 1, 'i': 2, 'd': 3} + assert ms.write_ctrlpars(value) == value + assert ms.read_ctrlpars() == value + assert ms.read_p() == 1 + assert ms.read_i() == 2 + assert ms.read_d() == 3 + assert ms.write_i(5) == 5 + assert ms.write_d(0) == 0 + assert ms.read_ctrlpars() == {'p': 1, 'i': 5, 'd': 0} + assert set(Mod.ctrlpars.influences) == {'p', 'i', 'd'} + assert Mod.p.influences == ('ctrlpars',) + assert Mod.i.influences == ('ctrlpars',) + assert Mod.d.influences == ('ctrlpars',) + + +def test_readonly(): + class Mod(Module): + ctrlpars = StructParam('ctrlpar struct', dict( + p = Parameter('control parameter p', FloatRange()), + i = Parameter('control parameter i', FloatRange()), + d = Parameter('control parameter d', FloatRange()), + ), {'p': 'pp', 'i':'ii', 'd': 'dd'}, readonly=True) + + assert Mod.ctrlpars.readonly is True + assert Mod.pp.readonly is True + assert Mod.ii.readonly is True + assert Mod.dd.readonly is True + + +def test_order_dependence1(): + test_without_read_ctrlpars() + test_with_read_ctrlpars() + + +def test_order_dependence2(): + test_with_read_ctrlpars() + test_without_read_ctrlpars() diff --git a/test/test_lib.py b/test/test_lib.py index 03c09a9..0fb85e0 100644 --- a/test/test_lib.py +++ b/test/test_lib.py @@ -22,7 +22,7 @@ import pytest -from frappy.lib import parse_host_port +from frappy.lib import parse_host_port, merge_status @pytest.mark.parametrize('hostport, defaultport, result', [ @@ -46,3 +46,19 @@ def test_parse_host(hostport, defaultport, result): parse_host_port(hostport, defaultport) else: assert result == parse_host_port(hostport, defaultport) + + +@pytest.mark.parametrize('args, result', [ + ([(100, 'idle'), (200, 'warning')], + (200, 'warning')), + ([(300, 'ramping'), (300, 'within tolerance')], + (300, 'ramping, within tolerance')), + ([(300, 'ramping, within tolerance'), (300, 'within tolerance, slow'), (200, 'warning')], + (300, 'ramping, within tolerance, slow')), + # when a comma is used for other purposes than separating individual status texts, + # the behaviour might not be as desired. However, this case is somewhat constructed. + ([(100, 'blue, yellow is my favorite'), (100, 'white, blue, red is a bad color mix')], + (100, 'blue, yellow is my favorite, white, red is a bad color mix')), +]) +def test_merge_status(args, result): + assert merge_status(*args) == result diff --git a/test/test_server.py b/test/test_server.py new file mode 100644 index 0000000..fad6225 --- /dev/null +++ b/test/test_server.py @@ -0,0 +1,55 @@ +# ***************************************************************************** +# +# 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: +# Alexander Zaft +# +# ***************************************************************************** + +import pytest +# pylint: disable=redefined-outer-name + +from frappy.server import Server + +from .test_config import direc # pylint: disable=unused-import + + +class LoggerStub: + def debug(self, fmt, *args): + pass + + def getChild(self, *args): + return self + + info = warning = exception = error = debug + handlers = [] + + +@pytest.fixture +def log(): + return LoggerStub() + + +def test_name_only(direc, log): + """only see that this does not throw. get config from name.""" + s = Server('pyfile', log) + s._processCfg() + + +def test_file(direc, log): + """only see that this does not throw. get config from cfgfiles.""" + s = Server('foo', log, cfgfiles='pyfile_cfg.py') + s._processCfg() From f205cf76aa39070372b9ebd93fd9a13f7a1cf772 Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Fri, 5 May 2023 16:01:06 +0200 Subject: [PATCH 02/14] mlz: Add Zebra Barcode Reader Adds a Barcode reader device (for now, only for ANTARES). Not yet tested with real hardware. Change-Id: I25f097466be89d152f47b9d05ece8f562e4b34d6 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31412 Reviewed-by: Georg Brandl Tested-by: Jenkins Automated Tests Reviewed-by: Alexander Zaft --- frappy_mlz/zebra.py | 286 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 frappy_mlz/zebra.py diff --git a/frappy_mlz/zebra.py b/frappy_mlz/zebra.py new file mode 100644 index 0000000..e7165b9 --- /dev/null +++ b/frappy_mlz/zebra.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +# ***************************************************************************** +# MLZ library of Tango servers +# Copyright (c) 2015-2023 by the authors, see LICENSE +# +# 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: +# Georg Brandl +# Alexander Zaft +# +# ***************************************************************************** + +import threading +from time import sleep, time + +from frappy.core import Parameter, Command, nopoll +from frappy.io import HasIO, BytesIO +from frappy.lib import mkthread +from frappy.errors import CommunicationFailedError +from frappy.datatypes import IntRange, StringType, StatusType + +# SSI protocol operations +CMD_ACK = 0xD0 +CMD_NAK = 0xD1 +DECODE_DATA = 0xF3 +BEEP = 0xE6 +REQUEST_REVISION = 0xA3 +REPLY_REVISION = 0xA4 +SCAN_ENABLE = 0xE9 +SCAN_DISABLE = 0xEA + +# source byte +HOST = 4 +DECODER = 0 + + +BARCODE_TYPES = { + 0x2d: 'Aztec', + 0x2e: 'Aztec Rune', + 0x16: 'Bookland', + 0x72: 'C 2 of 5', + 0x02: 'Codabar', + 0x0c: 'Code 11', + 0x03: 'Code 128', + 0x12: 'Code 16K', + 0x20: 'Code 32', + 0x01: 'Code 39', + 0x13: 'Code 39 ASCII', + 0x0d: 'Code 49', + 0x07: 'Code 93', + 0x17: 'Coupon', + 0x38: 'Cue CAT', + 0x04: 'D25', + 0x1b: 'Data Matrix', + 0x0f: 'GS1-128', + 0xc2: 'GS1 QR', + 0x0b: 'EAN-13', + 0x4b: 'EAN-13 + 2', + 0x8b: 'EAN-13 + 5', + 0x0a: 'EAN-8', + 0x4a: 'EAN-8 + 2', + 0x8a: 'EAN-8 + 5', + 0x2f: 'French Lottery', + 0x32: 'GS1 DataBar Expanded', + 0x31: 'GS1 DataBar Limited', + 0x30: 'GS1 DataBar-14', + 0xc1: 'GS1 Datamatrix', + 0xb7: 'Han Xin', + 0x05: 'IATA', + 0x19: 'ISBT-128', + 0x21: 'ISBT-128 Concat', + 0x36: 'ISSN', + 0x06: 'ITF', + 0x73: 'Korean 2 of 5', + 0x9a: 'Macro Micro PDF', + 0x28: 'Macro PDF-417', + 0x29: 'Macro QR', + 0x39: 'Matrix 2 of 5', + 0x25: 'Maxicode', + 0x1a: 'Micro PDF', + 0x1d: 'Micro PDF CCA', + 0x2c: 'Micro QR', + 0x0e: 'MSI', + 0x99: 'Multipacket Format', + 0x18: 'NW7', + 0xa0: 'OCRB', + 0x33: 'Parameter FNC3', + 0x11: 'PDF-417', + 0x1f: 'Planet US', + 0x23: 'Postal AUS', + 0x24: 'Postal NL', + 0x22: 'Postal JAP', + 0x27: 'Postal UK', + 0x26: 'Postbar CA', + 0x1e: 'Postnet US', + 0x1c: 'QR', + 0xe0: 'RFID Raw', + 0xe1: 'RFID URI', + 0xb4: 'RSS Expanded', + 0x37: 'Scanlet Webcode', + 0x69: 'Signature', + 0x5a: 'TLC-39', + 0x15: 'Trioptic', + 0x08: 'UPCA', + 0x48: 'UPCA + 2', + 0x88: 'UPCA + 5', + 0x14: 'UPCD', + 0x09: 'UPCE', + 0x49: 'UPCE + 2', + 0x89: 'UPCE + 5', + 0x10: 'UPCE1', + 0x50: 'UPCE1 + 2', + 0x90: 'UPCE1 + 5', + 0x34: '4State US', + 0x35: '4State US4', +} + + +def decode_bytes(byte_list): + return bytes(byte_list).decode('latin1') + + +class ZebraIO(BytesIO): + default_settings = {'baudrate': 115200} + + def _cksum(self, data): + cksum = 0x10000 - sum(data) + return [cksum >> 8, cksum & 0xFF] + + def _make_package(self, op, data): + msg = [len(data) + 4, op, HOST, 0] + data + return msg + self._cksum(msg) + + def _ssi_send(self, op, data): + self.communicate(self._make_package(op, data), 0) + + def _ssi_read_n(self, n, timeout, buf): + # read N bytes with specified timeout + end = time() + timeout + delay = 0.00005 + while n and time() < end: + sleep(delay) + delay = min(2 * delay, 0.01) + newdata = self.readBytes(int(n)) + n -= len(newdata) + buf.extend(newdata) + return buf + + def _ssi_recv(self, expected_op, recv_timeout, rest_timeout): + # first determine how much data there is to read + buf = [] + if not self._ssi_read_n(1, recv_timeout, buf): + return None + # now read the rest of the data + rest_len = buf[0] + 1 + self._ssi_read_n(rest_len, rest_timeout, buf) + if len(buf) != rest_len + 1: + return None + if buf[2] != DECODER: + raise CommunicationFailedError('invalid reply received') + if self._cksum(buf[:-2]) != buf[-2:]: + raise CommunicationFailedError('invalid checksum received') + if buf[1] != expected_op: + raise CommunicationFailedError('got op %r, expected %r' % + (buf[0], expected_op)) + return buf[4:-2] + + def _ssi_comm(self, op, data): + self._ssi_send(op, data) + if self._ssi_recv(CMD_ACK, 1, 1) is None: + raise CommunicationFailedError('ACK not received') + + +# Not yet tested +class ZebraReader(HasIO): + """Reads scanned barcodes from a Zebra barcode reader, using the USB-CDC + interface mode and the SSI protocol. + + TODO: CHANGE this paragraph + The underlying IO device must be a BinaryIO since SSI framing and metadata + is transferred in binary. + + Since reading barcodes is initiated by the device and not the host, the + parameter decoded does not give the last decoded value when polled. + Instead, activate updates for this parameter, which are then sent out when + the barcode reader decodes a value. Polling will always return an empty + string. + + The update for decoded then contains the decoded barcode type as a string, + a comma as a separator, and then the barcode data. + + As a special API, there is a Beep command to make the reader emit some + audible signal. + """ + + ioClass = ZebraIO + + decoded = Parameter('decoded barcode (updates-only)', StringType(), update_unchanged='always') + # TODO: Decide, if this is useful, remove otherwise + status = Parameter('status of the module', StatusType('IDLE', 'WARN', 'ERROR')) + + _thread = None + _stoprequest = False + + def initModule(self): # or startModule? + super().initModule() + self.io._ssi_send(REQUEST_REVISION, []) + rev = self.io._ssi_recv(REPLY_REVISION, 1, 1) + if rev is None: + raise CommunicationFailedError('got no revision info from decoder') + self.hw_version = decode_bytes(rev).split()[0] + + self._lock = threading.Lock() + self._thread = mkthread(self._thread_func) + + def shutdownModule(self): + self._stoprequest = True + if self._thread and self._thread.is_alive(): + self._thread.join() + + @nopoll + def read_decoded(self): + return '' # TODO: maybe raise Error? + + def read_status(self): + return self.Status.IDLE, '' + + def _thread_func(self): + while not self._stoprequest: + with self._lock: + try: + code = self.io._ssi_recv(DECODE_DATA, 0.1, 1) + if code is not None: + self.io._ssi_send(CMD_ACK, []) + # TODO: readBytes from BytesIO always uses self.timeout, so the + # case where None can be returned after the timeout cannot be + # used + except TimeoutError: + code = None + except Exception as e: + self.log.exception('while receiving barcode: %s', e) + continue + if code is not None: + codetype = BARCODE_TYPES.get(code[0], str(code[0])) + code = codetype + ',' + decode_bytes(code[1:]) + + tstamp = time() + self.log.info('decoded barcode %r with timestamp %s', + code, tstamp) + self.decoded = code + sleep(0.5) + + @Command() + def on(self): + """Enable the Scanner""" + with self._lock: + self.io._ssi_comm(SCAN_ENABLE, []) + + @Command() + def off(self): + """Disable the Scanner""" + with self._lock: + self.io._ssi_comm(SCAN_DISABLE, []) + + @Command(IntRange(0,26)) + def beep(self, pattern): + """ + Emit an audible signal from the reader. + :param pattern: The beep pattern (range 0 to 26; + see the manual for interpretation). + """ + with self._lock: + self.io._ssi_comm(BEEP, [pattern]) From 9ea6082ed88b31c613d01adc7e44c0c510797dad Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Wed, 12 Jul 2023 16:10:10 +0200 Subject: [PATCH 03/14] frappy_mlz: Zebra fixes after basic test Some fixes after the device was tested with socat ptys and NICOS. Change-Id: I3e9dba2be2547d493c435d1da9844c932a2df4e6 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31662 Tested-by: Jenkins Automated Tests Reviewed-by: Georg Brandl Reviewed-by: Alexander Zaft --- frappy_mlz/zebra.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/frappy_mlz/zebra.py b/frappy_mlz/zebra.py index e7165b9..78c4d20 100644 --- a/frappy_mlz/zebra.py +++ b/frappy_mlz/zebra.py @@ -26,7 +26,7 @@ import threading from time import sleep, time -from frappy.core import Parameter, Command, nopoll +from frappy.core import Parameter, Command, nopoll, Readable from frappy.io import HasIO, BytesIO from frappy.lib import mkthread from frappy.errors import CommunicationFailedError @@ -185,7 +185,7 @@ class ZebraIO(BytesIO): # Not yet tested -class ZebraReader(HasIO): +class ZebraReader(HasIO, Readable): """Reads scanned barcodes from a Zebra barcode reader, using the USB-CDC interface mode and the SSI protocol. @@ -208,9 +208,13 @@ class ZebraReader(HasIO): ioClass = ZebraIO - decoded = Parameter('decoded barcode (updates-only)', StringType(), update_unchanged='always') + decoded = Parameter('decoded barcode (updates-only)', StringType(), + update_unchanged='always', default='') # TODO: Decide, if this is useful, remove otherwise - status = Parameter('status of the module', StatusType('IDLE', 'WARN', 'ERROR')) + status = Parameter('status of the module', + StatusType('IDLE', 'WARN', 'ERROR')) + value = Parameter(datatype=StringType(), default='', + update_unchanged='never') _thread = None _stoprequest = False @@ -231,9 +235,13 @@ class ZebraReader(HasIO): if self._thread and self._thread.is_alive(): self._thread.join() + @nopoll + def read_value(self): + return '' + @nopoll def read_decoded(self): - return '' # TODO: maybe raise Error? + return '' def read_status(self): return self.Status.IDLE, '' @@ -252,6 +260,7 @@ class ZebraReader(HasIO): code = None except Exception as e: self.log.exception('while receiving barcode: %s', e) + self.status = self.Status.ERROR, f'{e!r}' continue if code is not None: codetype = BARCODE_TYPES.get(code[0], str(code[0])) @@ -261,6 +270,7 @@ class ZebraReader(HasIO): self.log.info('decoded barcode %r with timestamp %s', code, tstamp) self.decoded = code + self.decoded = '' # clear value of frappy client cache sleep(0.5) @Command() From 5168e0133d1a77b05da1a7ff4c02d89e7e06a1f9 Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Tue, 18 Jul 2023 10:35:57 +0200 Subject: [PATCH 04/14] dispatcher: change logging calls to debug Some logging calls should not have landed as log.info in the dynamic modules patch. This fixes that. Change-Id: I666fc7c9b5c65ddbed1c26ea456becce7870e744 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31707 Tested-by: Jenkins Automated Tests Reviewed-by: Alexander Zaft --- frappy/protocol/dispatcher.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappy/protocol/dispatcher.py b/frappy/protocol/dispatcher.py index b7858fe..37a4927 100644 --- a/frappy/protocol/dispatcher.py +++ b/frappy/protocol/dispatcher.py @@ -165,7 +165,7 @@ class Dispatcher: return modobj # also call earlyInit on the modules - self.log.info('initializing module %r', modulename) # TODO: change to debug + self.log.debug('initializing module %r', modulename) try: modobj.earlyInit() if not modobj.earlyInitDone: @@ -179,7 +179,7 @@ class Dispatcher: self.traceback_counter += 1 self.errors.append(f'error initializing {modulename}: {e!r}') modobj._isinitialized = True - self.log.info('initialized module %r', modulename) # TODO: change to debug + self.log.debug('initialized module %r', modulename) return modobj def get_module_instance(self, modulename): @@ -195,7 +195,7 @@ class Dispatcher: # it's actually already the module object return modulename # create module from srv.module_cfg, store and return - self.log.info('registering module %r', modulename) + self.log.debug('attempting to create module %r', modulename) opts = self.srv.module_cfg.get(modulename, None) if opts is None: From 3b63e32395529d0531caf23f7d96c55a5be11276 Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Fri, 21 Jul 2023 10:12:17 +0200 Subject: [PATCH 05/14] frappy_mlz: fix one-off error in barcode reader cut of one byte too much in barcode decode Change-Id: I5f1f8475f197b13af836d685dc6da5a9ee824dc2 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31728 Tested-by: Jenkins Automated Tests Reviewed-by: Alexander Zaft --- frappy_mlz/zebra.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappy_mlz/zebra.py b/frappy_mlz/zebra.py index 78c4d20..efcca17 100644 --- a/frappy_mlz/zebra.py +++ b/frappy_mlz/zebra.py @@ -176,7 +176,7 @@ class ZebraIO(BytesIO): if buf[1] != expected_op: raise CommunicationFailedError('got op %r, expected %r' % (buf[0], expected_op)) - return buf[4:-2] + return buf[3:-2] def _ssi_comm(self, op, data): self._ssi_send(op, data) From b844b8335271f24b7d69cf3bec4b7c519ce0891b Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Wed, 19 Jul 2023 15:35:02 +0200 Subject: [PATCH 06/14] core: do not call register_module on error Dispatcher.get_module_instance returns None on failure. If that is the case, the dispatcher should not try to register the None value as a module. Change-Id: Ie33b8debc2a829d480d56cafc1eb0ab610181d67 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31713 Reviewed-by: Enrico Faulhaber Reviewed-by: Alexander Zaft Tested-by: Jenkins Automated Tests --- frappy/protocol/dispatcher.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappy/protocol/dispatcher.py b/frappy/protocol/dispatcher.py index 37a4927..cc76225 100644 --- a/frappy/protocol/dispatcher.py +++ b/frappy/protocol/dispatcher.py @@ -239,8 +239,9 @@ class Dispatcher: self.traceback_counter += 1 self.errors.append(f'error creating {modulename}') modobj = None - self.register_module(modobj, modulename, modobj.export) - self.srv.modules[modulename] = modobj # IS HERE THE CORRECT PLACE? + if modobj: + self.register_module(modobj, modulename, modobj.export) + self.srv.modules[modulename] = modobj # IS HERE THE CORRECT PLACE? return modobj def remove_module(self, modulename_or_obj): From 4af46a0ea258746ae357e155076bfa25d2da481d Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Thu, 20 Jul 2023 07:36:54 +0200 Subject: [PATCH 07/14] add zapf to requirements-dev.txt Change-Id: Ia4de696051cee1e00676e777b7dd2c0a90a0c504 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31719 Tested-by: Jenkins Automated Tests Reviewed-by: Alexander Zaft --- requirements-dev.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4298010..8fa0590 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,3 +7,6 @@ markdown>=2.6 pytest pytest-randomly>=1.1 pytest-cov +# frappy_mlz +--extra-index-url https://forge.frm2.tum.de/simple +zapf >= 0.4.7 From 9dab41441f9bfb099afb05e5683666d25b5e1412 Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Wed, 28 Jun 2023 09:02:05 +0200 Subject: [PATCH 08/14] frappy_mlz: Add Zapf PLC adds a zapf-based PLC connection scanner. Change-Id: Icc0ded7e7a8cc5a83d7527d9b26b37c49e9b8674 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31471 Tested-by: Jenkins Automated Tests Reviewed-by: Enrico Faulhaber Reviewed-by: Alexander Zaft --- frappy_mlz/plc_zapf.py | 363 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 frappy_mlz/plc_zapf.py diff --git a/frappy_mlz/plc_zapf.py b/frappy_mlz/plc_zapf.py new file mode 100644 index 0000000..4fb45d6 --- /dev/null +++ b/frappy_mlz/plc_zapf.py @@ -0,0 +1,363 @@ +# ***************************************************************************** +# +# 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: +# Alexander Zaft +# +# ***************************************************************************** + +import re + +import zapf +import zapf.spec as zspec +from zapf.io import PlcIO +from zapf.scan import Scanner + +from frappy.core import BUSY, DISABLED, ERROR, FINALIZING, IDLE, \ + INITIALIZING, STARTING, UNKNOWN, WARN, Attached, Command, Communicator, \ + Drivable, Parameter, Property, Readable +from frappy.datatypes import UNLIMITED, ArrayOf, BLOBType, EnumType, \ + FloatRange, IntRange, StatusType, StringType, ValueType +from frappy.dynamic import Pinata +from frappy.errors import CommunicationFailedError, ImpossibleError, \ + IsBusyError, NoSuchParameterError, ReadOnlyError + +# Untested with real hardware, only testplc_2021_09.py + + +def internalize_name(name): + return re.sub(r'[^a-zA-Z0-9_]+', '_', name, re.ASCII) + + +ERROR_MAP = { + # should not happen. but better to have it here anyway + 5: NoSuchParameterError, + # if this occurs, something may have gone wrong with digesting the scanner + # data + 6: ReadOnlyError, + # Most likely from devices you cannot poll when busy. + 7: IsBusyError, +} + + +class ZapfPinata(Pinata): + """The Pinata device for a PLC that can be accessed according to PILS. + + See https://forge.frm2.tum.de/public/doc/plc/master/html/ + + Instantiates the classes with the base mapped class, which will be replaced + by initModule, so modules can also be configured manually in the config + file. + """ + iodev = Property('Connection to PLC', StringType()) + + def scanModules(self): + try: + self._plcio = PlcIO(self.iodev, self.log) + except zapf.CommError as e: + raise CommunicationFailedError('could not connect to plc') from e + scanner = Scanner(self._plcio, self.log) + for devinfo in scanner.scan_devices(): + if zspec.LOWLEVEL in devinfo.info.get('flags'): + self.log.debug('device %d (%s) is lowlevel, skipping', + devinfo.number, devinfo.name) + continue + device = scanner.get_device(devinfo) + if device is None: + self.log.info(f'{devinfo.name} unsupported') + continue + basecls = CLS_MAP.get(device.__class__, None) + if basecls is None: + self.log.info('No mapping found for %s, (class %s)', + devinfo.name, device.__class__.__name__) + continue + mod_cls = basecls.makeModuleClass(device, devinfo) + config = { + 'cls': mod_cls, + 'plcio': device, + 'description': devinfo.info['description'], + 'plc_name': devinfo.name, + '_pinata': self.name, + } + if devinfo.info['basetype'] != 'enum' \ + and not issubclass(basecls, PLCCommunicator): + config['value'] = { + # internal limit here is 2**64, zapf reports 2**128 + 'min': max(devinfo.info['absmin'], -UNLIMITED), + 'max': min(devinfo.info['absmax'], UNLIMITED), + } + if devinfo.info['access'] == 'rw': + config['target'] = { + 'min': config['value']['min'], + 'max': config['value']['max'], + } + name = internalize_name(devinfo.name) + yield (name, config) + self._plcio.start_cache() + + def shutdownModule(self): + """Shutdown the module, _plcio might be invalid after this. Needs to be + recreated by scanModules.""" + self._plcio.stop_cache() + self._plcio.proto.disconnect() + + +STATUS_MAP = { + zspec.DevStatus.RESET: (INITIALIZING, 'resetting'), + zspec.DevStatus.IDLE: (IDLE, 'idle'), + zspec.DevStatus.DISABLED: (DISABLED, 'disabled'), + zspec.DevStatus.WARN: (WARN, 'warning'), + zspec.DevStatus.START: (STARTING, 'starting'), + zspec.DevStatus.BUSY: (BUSY, 'busy'), + zspec.DevStatus.STOP: (FINALIZING, 'stopping'), + zspec.DevStatus.ERROR: (ERROR, 'error (please reset)'), + zspec.DevStatus.DIAGNOSTIC_ERROR: (ERROR, 'hard error (please check plc)'), +} + + +class PLCBase: + status = Parameter(datatype=StatusType(Drivable, 'INITIALIZING', + 'DISABLED', 'STARTING')) + status_code = Parameter('raw internal status code', + IntRange(0, 2**32-1)) + plcio = Property('plc io device', ValueType()) + plc_name = Property('plc io device', StringType(), export=True) + _pinata = Attached(ZapfPinata) # TODO: make this automatic? + + @classmethod + def makeModuleClass(cls, device, devinfo): + # add parameters and commands according to device info + add_members = {} + # set correct enums for value/target + if devinfo.info['basetype'] == 'enum': + rmap = {v: k for k, v in devinfo.info['enum_r'].items()} + read_enum = EnumType(rmap) + add_members['value'] = Parameter(datatype=read_enum) + if hasattr(cls, 'target'): + #wmap = {k:v for k, v in devinfo.info['enum_w'].items()} + #write_enum = EnumType(wmap) + write_enum = EnumType(devinfo.info['enum_w']) + add_members['target'] = Parameter(datatype=write_enum) + + for parameter in device.list_params(): + info = devinfo.info['params'][parameter] + iname = internalize_name(parameter) + readonly = info.get('access', 'ro') != 'rw' + dataty = cls._map_datatype(info) + if dataty is None: + continue + param = Parameter(info['description'], + dataty, + readonly=readonly) + + def read_param(self, parameter=parameter): + code, val = self.plcio.get_param_raw(parameter) + if code > 4: + raise ERROR_MAP[code](f'Error when reading parameter' + f'{parameter}: {code}') + return val + + def write_param(self, value, parameter=parameter): + code, val = self.plcio.set_param_raw(parameter, value) + if code > 4: + raise ERROR_MAP[code](f'Error when setting parameter' + f'{parameter} to {value!r}: {code}') + return val + + # enums can have asymmetric read and write variants. this should be + # checked + if info['basetype'] == 'enum': + allowed = frozenset(info['enum_w'].values()) + #pylint: disable=function-redefined + def write_param(self, value, allowed=allowed, parameter=parameter): + if value not in allowed: + raise ValueError(f'Invalid value for writing' + f' {parameter}: {value!r}') + + code, val = self.plcio.set_param_raw(parameter, value) + if code > 4: + raise ERROR_MAP[code](f'Error when setting parameter' + f'{parameter} to {value!r}: {code}') + return val + + add_members[iname] = param + add_members['read_' + iname] = read_param + if readonly: + continue + add_members['write_' + iname] = write_param + + for command in device.list_funcs(): + info = devinfo.info['funcs'][command] + iname = internalize_name(command) + if info['argument']: + arg = cls._map_datatype(info['argument']) + else: + arg = None + if info['result']: + result = cls._map_datatype(info['result']) + else: + result = None + def exec_command(self, arg=None, command=command): + # TODO: commands return , + return self.plcio.exec_func(command, arg) + decorator = Command(arg, + result = result, + description=info['description'], + ) + + func = decorator(exec_command) + add_members['call_' + iname] = func + if not add_members: + return cls + new_name = '_' + cls.__name__ + '_' \ + + internalize_name("blub") + return type(new_name, (cls,), add_members) + + @classmethod + def _map_datatype(cls, info): + dataty = info['basetype'] + if dataty == 'int': + return IntRange(info['min_value'], info['max_value']) + if dataty == 'float': + return FloatRange(info['min_value'], info['max_value']) + if dataty == 'enum': + mapping = {v: k for k, v in info['enum_r'].items()} + return EnumType(mapping) + return None + + def read_status(self): + state, reason, aux, err_id = self.plcio.read_status() + if state in STATUS_MAP: + status, m = STATUS_MAP[state] + else: + status, m = UNKNOWN, 'unknown state 0x%x' % state + msg = [m] + reason = zapf.spec.ReasonMap[reason] + if reason: + msg.append(reason) + if aux: + msg.append(self.plcio.decode_aux(aux)) + if err_id: + msg.append(self.plcio.decode_errid(err_id)) + return status, ', '.join(msg) + + def read_status_code(self): + state, reason, aux, _ = self.plcio.read_status() + return state << 28 | reason << 24 | aux + + @Command() + def stop(self): + """Stop the operation of this module. + + :raises: + ImpossibleError: if the command is called while the module is + not busy + """ + if not self.plcio.change_status((zapf.DevStatus.BUSY,), + zapf.DevStatus.STOP): + self.log.info('stop was called when device was not busy') + # TODO: off/on? + + @Command() + def reset(self): + """Tries to reset this module. + + :raises: + ImpossibleError: when called while the module is not in an error + state. + """ + if not self.plcio.reset(): + raise ImpossibleError('reset called when the device is not in' + 'an error state!') + + +class PLCValue(PLCBase): + """Base class for all but Communicator""" + def read_value(self): + return self.plcio.read_value_raw() # read_value maps enums on zapf side + + def read_target(self): + return self.plcio.read_target_raw() + + def write_target(self, value): + self.plcio.change_target_raw(value) + + +class PLCReadable(PLCValue, Readable): + """Readable value, scanned from PLC.""" + description = Property('the modules description', + datatype=StringType(isUTF8=True)) + + +class PLCDrivable(PLCValue, Drivable): + """Drivable, scanned from PLC.""" + description = Property('the modules description', + datatype=StringType(isUTF8=True)) + + +class PLCCommunicator(PLCBase, Communicator): + status = Parameter('current status of the module') + + @Command(BLOBType(), result=BLOBType()) + def communicate(self, command): + return self.plcio.communicate(command) + + +class Sensor(PLCReadable): + pass + + +class AnalogOutput(PLCDrivable): + pass + + +class DiscreteInput(PLCReadable): + value = Parameter(datatype=IntRange()) + +class DiscreteOutput(PLCDrivable): + value = Parameter(datatype=IntRange()) + target = Parameter(datatype=IntRange()) + + +class VectorInput(PLCReadable): + value = Parameter(datatype=ArrayOf(FloatRange())) + + +class VectorOutput(PLCDrivable): + value = Parameter(datatype=ArrayOf(FloatRange())) + target = Parameter(datatype=ArrayOf(FloatRange())) + + +CLS_MAP = { + zapf.device.SimpleDiscreteIn: DiscreteInput, + zapf.device.SimpleAnalogIn: Sensor, + zapf.device.Keyword: DiscreteOutput, + zapf.device.RealValue: AnalogOutput, + zapf.device.SimpleDiscreteOut: DiscreteOutput, + zapf.device.SimpleAnalogOut: PLCDrivable, + zapf.device.StatusWord: DiscreteInput, + zapf.device.DiscreteIn: DiscreteInput, + zapf.device.AnalogIn: Sensor, + zapf.device.DiscreteOut: DiscreteOutput, + zapf.device.AnalogOut: PLCDrivable, + zapf.device.FlatIn: Sensor, + zapf.device.FlatOut: AnalogOutput, + zapf.device.ParamIn: Sensor, + zapf.device.ParamOut: AnalogOutput, + zapf.device.VectorIn: VectorInput, + zapf.device.VectorOut: VectorOutput, + zapf.device.MessageIO: PLCCommunicator, +} From 2474dc5e7223cd09168b55d8b4c282ceb28438ef Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Wed, 9 Aug 2023 14:19:39 +0200 Subject: [PATCH 09/14] interactive client: improve keyboard interrupt - when driving a module with (), keyboard interrupt should send stop() - make sure keyboard interrupt does not only stop the current driving, but also skips other code on the same command line Change-Id: Ib4d2c4111dc0f23bf07385065766fb9b4a611454 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31926 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker --- frappy/client/interactive.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/frappy/client/interactive.py b/frappy/client/interactive.py index ce02317..5614f2d 100644 --- a/frappy/client/interactive.py +++ b/frappy/client/interactive.py @@ -214,18 +214,28 @@ class Module: return self.read() self.target = target # this sets self._is_driving type(self).value.prev = None # show at least one value - try: + + def loop(): while self._is_driving: self._driving_event.wait() self._watch_parameter(self._name, 'value', mininterval=self._secnode.mininterval) self._watch_parameter(self._name, 'status') self._driving_event.clear() - except KeyboardInterrupt: - self.stop() + try: + loop() + except KeyboardInterrupt as e: self._secnode.log.info('-- interrupted --') - self._watch_parameter(self._name, 'status') - self._secnode.readParameter(self._name, 'value') - self._watch_parameter(self._name, 'value', forced=True) + self.stop() + try: + loop() # wait for stopping to be finished + except KeyboardInterrupt: + # interrupted again while stopping -> definitely quit + pass + clientenv.raise_with_short_traceback(e) + finally: + self._watch_parameter(self._name, 'status') + self._secnode.readParameter(self._name, 'value') + self._watch_parameter(self._name, 'value', forced=True) return self.value def __repr__(self): @@ -331,8 +341,8 @@ def watch(*args, **kwds): for mobj in modules: mobj._start_watching() time.sleep(3600) - except KeyboardInterrupt: - pass + except KeyboardInterrupt as e: + clientenv.raise_with_short_traceback(e) finally: for mobj in modules: mobj._stop_watching() From 09e59b93d876a2b1beb6be85121114be250f5691 Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Thu, 20 Jul 2023 15:21:30 +0200 Subject: [PATCH 10/14] Revert "add zapf to requirements-dev.txt" This reverts commit e67a46cd015c0a1a32d5a4f114b963dd17a7c266. Reason for revert: required version available from pypi Change-Id: Ib4f8b0cf62da58e84545511c7521ea93b7ff1342 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31724 Tested-by: Jenkins Automated Tests Reviewed-by: Alexander Zaft --- requirements-dev.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8fa0590..4298010 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,6 +7,3 @@ markdown>=2.6 pytest pytest-randomly>=1.1 pytest-cov -# frappy_mlz ---extra-index-url https://forge.frm2.tum.de/simple -zapf >= 0.4.7 From bc0133f55a47133474c04f84860593264cb46146 Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Thu, 20 Jul 2023 15:27:18 +0200 Subject: [PATCH 11/14] add zapf to requirements-dev Change-Id: I6dddd8d4c590253f1039b89edae561fa90b40811 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/31725 Tested-by: Jenkins Automated Tests Reviewed-by: Alexander Zaft --- requirements-dev.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 4298010..2c97239 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,3 +7,5 @@ markdown>=2.6 pytest pytest-randomly>=1.1 pytest-cov +# frappy mlz +zapf >= 0.4.7 From 9d9b5b2694913982fe4cc404ca08d4bdbf601563 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Wed, 23 Aug 2023 13:05:18 +0200 Subject: [PATCH 12/14] frappy_psi.phytron: further improvements unfortunaely, sometimes communication errors happen. workaround: try several times reading the status Change-Id: I2788c6c9b4145246cdd51c31b246abffee60f93b Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/32032 Tested-by: Jenkins Automated Tests Reviewed-by: Markus Zolliker --- frappy_psi/phytron.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frappy_psi/phytron.py b/frappy_psi/phytron.py index fdd3039..1705a50 100644 --- a/frappy_psi/phytron.py +++ b/frappy_psi/phytron.py @@ -192,9 +192,17 @@ class Motor(HasOffset, HasStates, PersistentMixin, HasIO, Drivable): self.hw_stop() def read_status(self): - sysstatus = self.communicate(f'{self.address:x}SE') - sysstatus = sysstatus[1:4] if self.axis == 'X' else sysstatus[5:8] - status = self.STATUS_MAP.get(sysstatus[1:]) or (ERROR, f'unknown error {sysstatus[1:]}') + for _ in range(3): + sysstatus = self.communicate(f'{self.address:x}SE') + try: + sysstatus = sysstatus[1:4] if self.axis == 'X' else sysstatus[5:8] + status = self.STATUS_MAP[sysstatus[1:]] + except Exception: # can not interprete the reply, probably communication error + self.log.warning('bad status reply %r', sysstatus) + continue + break + else: + status = (ERROR, f'unknown status after 3 tries {sysstatus!r}') self._running = sysstatus[0] != '1' if status[0] == ERROR: self._blocking_error = status[1] @@ -213,7 +221,7 @@ class Motor(HasOffset, HasStates, PersistentMixin, HasIO, Drivable): enc = self.read_encoder() else: enc = self.value - if not self._running: # at target + if not self._running: # at target (self._running is updated in self.read_status()) return False diff = abs(self.value - self._intermediate_target) if diff > self._prev_diff and diff > self.encoder_tolerance: From b9f046a66558380619a0790bd6c5d711fab0b014 Mon Sep 17 00:00:00 2001 From: sans Date: Wed, 6 Sep 2023 08:38:06 +0200 Subject: [PATCH 13/14] hvolt_short stick: make hcp writable --- cfg/sea/hvolt_short.stick.json | 2 +- cfg/stick/hvolt_short_cfg.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cfg/sea/hvolt_short.stick.json b/cfg/sea/hvolt_short.stick.json index 48c403e..9a36236 100644 --- a/cfg/sea/hvolt_short.stick.json +++ b/cfg/sea/hvolt_short.stick.json @@ -1,5 +1,5 @@ {"hcp": {"base": "/hcp", "params": [ -{"path": "", "type": "float", "kids": 10}, +{"path": "", "type": "float", "readonly": false, "cmd": "hcp set", "kids": 10}, {"path": "send", "type": "text", "readonly": false, "cmd": "hcp send", "visibility": 3}, {"path": "status", "type": "text", "visibility": 3}, {"path": "set", "type": "float", "readonly": false, "cmd": "hcp set"}, diff --git a/cfg/stick/hvolt_short_cfg.py b/cfg/stick/hvolt_short_cfg.py index 8256e83..de1bc93 100644 --- a/cfg/stick/hvolt_short_cfg.py +++ b/cfg/stick/hvolt_short_cfg.py @@ -18,7 +18,7 @@ Mod('ts', ) Mod('hcp', - 'frappy_psi.sea.SeaReadable', '', + 'frappy_psi.sea.SeaWritable', '', io='sea_stick', sea_object='hcp', ) From 833a68db516d8d251334b3f7eb3ec95a1ad57ca1 Mon Sep 17 00:00:00 2001 From: sans Date: Wed, 6 Sep 2023 08:38:49 +0200 Subject: [PATCH 14/14] ma10: improve sea cfg --- cfg/main/ma10_cfg.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cfg/main/ma10_cfg.py b/cfg/main/ma10_cfg.py index c77768a..844ab52 100644 --- a/cfg/main/ma10_cfg.py +++ b/cfg/main/ma10_cfg.py @@ -43,6 +43,7 @@ Mod('mf', 'frappy_psi.sea.SeaDrivable', '', io='sea_main', sea_object='mf', + rel_paths=['.', 'gen', 'ips'], ) Mod('lev',