API
++ | Lewis - a library for creating hardware device simulators |
+
diff --git a/.buildinfo b/.buildinfo new file mode 100644 index 00000000..5fb23eea --- /dev/null +++ b/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file records the configuration used when building these files. When it is not found, a full rebuild will be done. +config: cc0d7a238108b91a71d7022e49a51a70 +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/.doctrees/_api.doctree b/.doctrees/_api.doctree new file mode 100644 index 00000000..1c1dd662 Binary files /dev/null and b/.doctrees/_api.doctree differ diff --git a/.doctrees/developer_guide/developing_lewis.doctree b/.doctrees/developer_guide/developing_lewis.doctree new file mode 100644 index 00000000..713bb527 Binary files /dev/null and b/.doctrees/developer_guide/developing_lewis.doctree differ diff --git a/.doctrees/developer_guide/framework_details.doctree b/.doctrees/developer_guide/framework_details.doctree new file mode 100644 index 00000000..fbb178b6 Binary files /dev/null and b/.doctrees/developer_guide/framework_details.doctree differ diff --git a/.doctrees/developer_guide/release_checklist.doctree b/.doctrees/developer_guide/release_checklist.doctree new file mode 100644 index 00000000..eaf1f3ed Binary files /dev/null and b/.doctrees/developer_guide/release_checklist.doctree differ diff --git a/.doctrees/developer_guide/writing_devices.doctree b/.doctrees/developer_guide/writing_devices.doctree new file mode 100644 index 00000000..0c3ea1fb Binary files /dev/null and b/.doctrees/developer_guide/writing_devices.doctree differ diff --git a/.doctrees/environment.pickle b/.doctrees/environment.pickle new file mode 100644 index 00000000..a612634a Binary files /dev/null and b/.doctrees/environment.pickle differ diff --git a/.doctrees/generated/lewis.adapters.doctree b/.doctrees/generated/lewis.adapters.doctree new file mode 100644 index 00000000..8ca55e7b Binary files /dev/null and b/.doctrees/generated/lewis.adapters.doctree differ diff --git a/.doctrees/generated/lewis.adapters.epics.doctree b/.doctrees/generated/lewis.adapters.epics.doctree new file mode 100644 index 00000000..d9e6c26a Binary files /dev/null and b/.doctrees/generated/lewis.adapters.epics.doctree differ diff --git a/.doctrees/generated/lewis.adapters.modbus.doctree b/.doctrees/generated/lewis.adapters.modbus.doctree new file mode 100644 index 00000000..089295eb Binary files /dev/null and b/.doctrees/generated/lewis.adapters.modbus.doctree differ diff --git a/.doctrees/generated/lewis.adapters.stream.doctree b/.doctrees/generated/lewis.adapters.stream.doctree new file mode 100644 index 00000000..ffe76bb7 Binary files /dev/null and b/.doctrees/generated/lewis.adapters.stream.doctree differ diff --git a/.doctrees/generated/lewis.core.adapters.doctree b/.doctrees/generated/lewis.core.adapters.doctree new file mode 100644 index 00000000..a2a3ca36 Binary files /dev/null and b/.doctrees/generated/lewis.core.adapters.doctree differ diff --git a/.doctrees/generated/lewis.core.approaches.doctree b/.doctrees/generated/lewis.core.approaches.doctree new file mode 100644 index 00000000..5bef648f Binary files /dev/null and b/.doctrees/generated/lewis.core.approaches.doctree differ diff --git a/.doctrees/generated/lewis.core.control_client.doctree b/.doctrees/generated/lewis.core.control_client.doctree new file mode 100644 index 00000000..5755a0fc Binary files /dev/null and b/.doctrees/generated/lewis.core.control_client.doctree differ diff --git a/.doctrees/generated/lewis.core.control_server.doctree b/.doctrees/generated/lewis.core.control_server.doctree new file mode 100644 index 00000000..a5133c3d Binary files /dev/null and b/.doctrees/generated/lewis.core.control_server.doctree differ diff --git a/.doctrees/generated/lewis.core.devices.doctree b/.doctrees/generated/lewis.core.devices.doctree new file mode 100644 index 00000000..9b8b13f7 Binary files /dev/null and b/.doctrees/generated/lewis.core.devices.doctree differ diff --git a/.doctrees/generated/lewis.core.doctree b/.doctrees/generated/lewis.core.doctree new file mode 100644 index 00000000..9d90b94b Binary files /dev/null and b/.doctrees/generated/lewis.core.doctree differ diff --git a/.doctrees/generated/lewis.core.exceptions.doctree b/.doctrees/generated/lewis.core.exceptions.doctree new file mode 100644 index 00000000..4b03dfcf Binary files /dev/null and b/.doctrees/generated/lewis.core.exceptions.doctree differ diff --git a/.doctrees/generated/lewis.core.logging.doctree b/.doctrees/generated/lewis.core.logging.doctree new file mode 100644 index 00000000..44061803 Binary files /dev/null and b/.doctrees/generated/lewis.core.logging.doctree differ diff --git a/.doctrees/generated/lewis.core.processor.doctree b/.doctrees/generated/lewis.core.processor.doctree new file mode 100644 index 00000000..98e655da Binary files /dev/null and b/.doctrees/generated/lewis.core.processor.doctree differ diff --git a/.doctrees/generated/lewis.core.simulation.doctree b/.doctrees/generated/lewis.core.simulation.doctree new file mode 100644 index 00000000..715e03b2 Binary files /dev/null and b/.doctrees/generated/lewis.core.simulation.doctree differ diff --git a/.doctrees/generated/lewis.core.statemachine.doctree b/.doctrees/generated/lewis.core.statemachine.doctree new file mode 100644 index 00000000..e9e18d5f Binary files /dev/null and b/.doctrees/generated/lewis.core.statemachine.doctree differ diff --git a/.doctrees/generated/lewis.core.utils.doctree b/.doctrees/generated/lewis.core.utils.doctree new file mode 100644 index 00000000..aacfa5e3 Binary files /dev/null and b/.doctrees/generated/lewis.core.utils.doctree differ diff --git a/.doctrees/generated/lewis.devices.chopper.devices.bearings.doctree b/.doctrees/generated/lewis.devices.chopper.devices.bearings.doctree new file mode 100644 index 00000000..cb0149c1 Binary files /dev/null and b/.doctrees/generated/lewis.devices.chopper.devices.bearings.doctree differ diff --git a/.doctrees/generated/lewis.devices.chopper.devices.device.doctree b/.doctrees/generated/lewis.devices.chopper.devices.device.doctree new file mode 100644 index 00000000..531a8dab Binary files /dev/null and b/.doctrees/generated/lewis.devices.chopper.devices.device.doctree differ diff --git a/.doctrees/generated/lewis.devices.chopper.devices.doctree b/.doctrees/generated/lewis.devices.chopper.devices.doctree new file mode 100644 index 00000000..58cb816d Binary files /dev/null and b/.doctrees/generated/lewis.devices.chopper.devices.doctree differ diff --git a/.doctrees/generated/lewis.devices.chopper.devices.states.doctree b/.doctrees/generated/lewis.devices.chopper.devices.states.doctree new file mode 100644 index 00000000..db28a884 Binary files /dev/null and b/.doctrees/generated/lewis.devices.chopper.devices.states.doctree differ diff --git a/.doctrees/generated/lewis.devices.chopper.doctree b/.doctrees/generated/lewis.devices.chopper.doctree new file mode 100644 index 00000000..aad519a3 Binary files /dev/null and b/.doctrees/generated/lewis.devices.chopper.doctree differ diff --git a/.doctrees/generated/lewis.devices.chopper.interfaces.doctree b/.doctrees/generated/lewis.devices.chopper.interfaces.doctree new file mode 100644 index 00000000..aae8e8c1 Binary files /dev/null and b/.doctrees/generated/lewis.devices.chopper.interfaces.doctree differ diff --git a/.doctrees/generated/lewis.devices.chopper.interfaces.epics_interface.doctree b/.doctrees/generated/lewis.devices.chopper.interfaces.epics_interface.doctree new file mode 100644 index 00000000..3e3d1ff9 Binary files /dev/null and b/.doctrees/generated/lewis.devices.chopper.interfaces.epics_interface.doctree differ diff --git a/.doctrees/generated/lewis.devices.doctree b/.doctrees/generated/lewis.devices.doctree new file mode 100644 index 00000000..30a52daa Binary files /dev/null and b/.doctrees/generated/lewis.devices.doctree differ diff --git a/.doctrees/generated/lewis.devices.julabo.devices.device.doctree b/.doctrees/generated/lewis.devices.julabo.devices.device.doctree new file mode 100644 index 00000000..289d2d8e Binary files /dev/null and b/.doctrees/generated/lewis.devices.julabo.devices.device.doctree differ diff --git a/.doctrees/generated/lewis.devices.julabo.devices.doctree b/.doctrees/generated/lewis.devices.julabo.devices.doctree new file mode 100644 index 00000000..5e25ebd0 Binary files /dev/null and b/.doctrees/generated/lewis.devices.julabo.devices.doctree differ diff --git a/.doctrees/generated/lewis.devices.julabo.devices.states.doctree b/.doctrees/generated/lewis.devices.julabo.devices.states.doctree new file mode 100644 index 00000000..611d0823 Binary files /dev/null and b/.doctrees/generated/lewis.devices.julabo.devices.states.doctree differ diff --git a/.doctrees/generated/lewis.devices.julabo.doctree b/.doctrees/generated/lewis.devices.julabo.doctree new file mode 100644 index 00000000..32035f2d Binary files /dev/null and b/.doctrees/generated/lewis.devices.julabo.doctree differ diff --git a/.doctrees/generated/lewis.devices.julabo.interfaces.doctree b/.doctrees/generated/lewis.devices.julabo.interfaces.doctree new file mode 100644 index 00000000..ad8cd31b Binary files /dev/null and b/.doctrees/generated/lewis.devices.julabo.interfaces.doctree differ diff --git a/.doctrees/generated/lewis.devices.julabo.interfaces.julabo_stream_interface_1.doctree b/.doctrees/generated/lewis.devices.julabo.interfaces.julabo_stream_interface_1.doctree new file mode 100644 index 00000000..6c526b26 Binary files /dev/null and b/.doctrees/generated/lewis.devices.julabo.interfaces.julabo_stream_interface_1.doctree differ diff --git a/.doctrees/generated/lewis.devices.julabo.interfaces.julabo_stream_interface_2.doctree b/.doctrees/generated/lewis.devices.julabo.interfaces.julabo_stream_interface_2.doctree new file mode 100644 index 00000000..744a1d8e Binary files /dev/null and b/.doctrees/generated/lewis.devices.julabo.interfaces.julabo_stream_interface_2.doctree differ diff --git a/.doctrees/generated/lewis.devices.linkam_t95.devices.device.doctree b/.doctrees/generated/lewis.devices.linkam_t95.devices.device.doctree new file mode 100644 index 00000000..192e881e Binary files /dev/null and b/.doctrees/generated/lewis.devices.linkam_t95.devices.device.doctree differ diff --git a/.doctrees/generated/lewis.devices.linkam_t95.devices.doctree b/.doctrees/generated/lewis.devices.linkam_t95.devices.doctree new file mode 100644 index 00000000..d456482d Binary files /dev/null and b/.doctrees/generated/lewis.devices.linkam_t95.devices.doctree differ diff --git a/.doctrees/generated/lewis.devices.linkam_t95.devices.states.doctree b/.doctrees/generated/lewis.devices.linkam_t95.devices.states.doctree new file mode 100644 index 00000000..c22c3ad2 Binary files /dev/null and b/.doctrees/generated/lewis.devices.linkam_t95.devices.states.doctree differ diff --git a/.doctrees/generated/lewis.devices.linkam_t95.doctree b/.doctrees/generated/lewis.devices.linkam_t95.doctree new file mode 100644 index 00000000..53adbd03 Binary files /dev/null and b/.doctrees/generated/lewis.devices.linkam_t95.doctree differ diff --git a/.doctrees/generated/lewis.devices.linkam_t95.interfaces.doctree b/.doctrees/generated/lewis.devices.linkam_t95.interfaces.doctree new file mode 100644 index 00000000..273feea3 Binary files /dev/null and b/.doctrees/generated/lewis.devices.linkam_t95.interfaces.doctree differ diff --git a/.doctrees/generated/lewis.devices.linkam_t95.interfaces.stream_interface.doctree b/.doctrees/generated/lewis.devices.linkam_t95.interfaces.stream_interface.doctree new file mode 100644 index 00000000..312fc30e Binary files /dev/null and b/.doctrees/generated/lewis.devices.linkam_t95.interfaces.stream_interface.doctree differ diff --git a/.doctrees/generated/lewis.doctree b/.doctrees/generated/lewis.doctree new file mode 100644 index 00000000..c1409864 Binary files /dev/null and b/.doctrees/generated/lewis.doctree differ diff --git a/.doctrees/generated/lewis.examples.doctree b/.doctrees/generated/lewis.examples.doctree new file mode 100644 index 00000000..55ac695c Binary files /dev/null and b/.doctrees/generated/lewis.examples.doctree differ diff --git a/.doctrees/generated/lewis.examples.dual_device.doctree b/.doctrees/generated/lewis.examples.dual_device.doctree new file mode 100644 index 00000000..9acfdce8 Binary files /dev/null and b/.doctrees/generated/lewis.examples.dual_device.doctree differ diff --git a/.doctrees/generated/lewis.examples.example_motor.doctree b/.doctrees/generated/lewis.examples.example_motor.doctree new file mode 100644 index 00000000..07aa36b7 Binary files /dev/null and b/.doctrees/generated/lewis.examples.example_motor.doctree differ diff --git a/.doctrees/generated/lewis.examples.modbus_device.doctree b/.doctrees/generated/lewis.examples.modbus_device.doctree new file mode 100644 index 00000000..b212b98c Binary files /dev/null and b/.doctrees/generated/lewis.examples.modbus_device.doctree differ diff --git a/.doctrees/generated/lewis.examples.simple_device.doctree b/.doctrees/generated/lewis.examples.simple_device.doctree new file mode 100644 index 00000000..0de282b7 Binary files /dev/null and b/.doctrees/generated/lewis.examples.simple_device.doctree differ diff --git a/.doctrees/generated/lewis.examples.timeout_device.doctree b/.doctrees/generated/lewis.examples.timeout_device.doctree new file mode 100644 index 00000000..e923d4c0 Binary files /dev/null and b/.doctrees/generated/lewis.examples.timeout_device.doctree differ diff --git a/.doctrees/generated/lewis.scripts.control.doctree b/.doctrees/generated/lewis.scripts.control.doctree new file mode 100644 index 00000000..86cf6870 Binary files /dev/null and b/.doctrees/generated/lewis.scripts.control.doctree differ diff --git a/.doctrees/generated/lewis.scripts.doctree b/.doctrees/generated/lewis.scripts.doctree new file mode 100644 index 00000000..a73ce051 Binary files /dev/null and b/.doctrees/generated/lewis.scripts.doctree differ diff --git a/.doctrees/generated/lewis.scripts.run.doctree b/.doctrees/generated/lewis.scripts.run.doctree new file mode 100644 index 00000000..30000a69 Binary files /dev/null and b/.doctrees/generated/lewis.scripts.run.doctree differ diff --git a/.doctrees/generated/lewis.utils.byte_conversions.doctree b/.doctrees/generated/lewis.utils.byte_conversions.doctree new file mode 100644 index 00000000..66236d93 Binary files /dev/null and b/.doctrees/generated/lewis.utils.byte_conversions.doctree differ diff --git a/.doctrees/generated/lewis.utils.command_builder.doctree b/.doctrees/generated/lewis.utils.command_builder.doctree new file mode 100644 index 00000000..bf9eba4f Binary files /dev/null and b/.doctrees/generated/lewis.utils.command_builder.doctree differ diff --git a/.doctrees/generated/lewis.utils.constants.doctree b/.doctrees/generated/lewis.utils.constants.doctree new file mode 100644 index 00000000..229b7319 Binary files /dev/null and b/.doctrees/generated/lewis.utils.constants.doctree differ diff --git a/.doctrees/generated/lewis.utils.doctree b/.doctrees/generated/lewis.utils.doctree new file mode 100644 index 00000000..2f3d6d0f Binary files /dev/null and b/.doctrees/generated/lewis.utils.doctree differ diff --git a/.doctrees/generated/lewis.utils.replies.doctree b/.doctrees/generated/lewis.utils.replies.doctree new file mode 100644 index 00000000..f9bd8aba Binary files /dev/null and b/.doctrees/generated/lewis.utils.replies.doctree differ diff --git a/.doctrees/index.doctree b/.doctrees/index.doctree new file mode 100644 index 00000000..c792fe45 Binary files /dev/null and b/.doctrees/index.doctree differ diff --git a/.doctrees/quickstart.doctree b/.doctrees/quickstart.doctree new file mode 100644 index 00000000..09fe892b Binary files /dev/null and b/.doctrees/quickstart.doctree differ diff --git a/.doctrees/release_notes/release_1_0_0.doctree b/.doctrees/release_notes/release_1_0_0.doctree new file mode 100644 index 00000000..e0d519c7 Binary files /dev/null and b/.doctrees/release_notes/release_1_0_0.doctree differ diff --git a/.doctrees/release_notes/release_1_0_1.doctree b/.doctrees/release_notes/release_1_0_1.doctree new file mode 100644 index 00000000..ee18e76b Binary files /dev/null and b/.doctrees/release_notes/release_1_0_1.doctree differ diff --git a/.doctrees/release_notes/release_1_0_2.doctree b/.doctrees/release_notes/release_1_0_2.doctree new file mode 100644 index 00000000..e31927cf Binary files /dev/null and b/.doctrees/release_notes/release_1_0_2.doctree differ diff --git a/.doctrees/release_notes/release_1_0_3.doctree b/.doctrees/release_notes/release_1_0_3.doctree new file mode 100644 index 00000000..b45fe6b7 Binary files /dev/null and b/.doctrees/release_notes/release_1_0_3.doctree differ diff --git a/.doctrees/release_notes/release_1_1_0.doctree b/.doctrees/release_notes/release_1_1_0.doctree new file mode 100644 index 00000000..2602b593 Binary files /dev/null and b/.doctrees/release_notes/release_1_1_0.doctree differ diff --git a/.doctrees/release_notes/release_1_1_1.doctree b/.doctrees/release_notes/release_1_1_1.doctree new file mode 100644 index 00000000..5e6fb2ea Binary files /dev/null and b/.doctrees/release_notes/release_1_1_1.doctree differ diff --git a/.doctrees/release_notes/release_1_2_0.doctree b/.doctrees/release_notes/release_1_2_0.doctree new file mode 100644 index 00000000..cc10a24b Binary files /dev/null and b/.doctrees/release_notes/release_1_2_0.doctree differ diff --git a/.doctrees/release_notes/release_1_2_1.doctree b/.doctrees/release_notes/release_1_2_1.doctree new file mode 100644 index 00000000..47ca24b9 Binary files /dev/null and b/.doctrees/release_notes/release_1_2_1.doctree differ diff --git a/.doctrees/release_notes/release_1_2_2.doctree b/.doctrees/release_notes/release_1_2_2.doctree new file mode 100644 index 00000000..04f55373 Binary files /dev/null and b/.doctrees/release_notes/release_1_2_2.doctree differ diff --git a/.doctrees/release_notes/release_1_3_0.doctree b/.doctrees/release_notes/release_1_3_0.doctree new file mode 100644 index 00000000..b9a8b927 Binary files /dev/null and b/.doctrees/release_notes/release_1_3_0.doctree differ diff --git a/.doctrees/release_notes/release_1_3_1.doctree b/.doctrees/release_notes/release_1_3_1.doctree new file mode 100644 index 00000000..21b28969 Binary files /dev/null and b/.doctrees/release_notes/release_1_3_1.doctree differ diff --git a/.doctrees/release_notes/release_1_3_2.doctree b/.doctrees/release_notes/release_1_3_2.doctree new file mode 100644 index 00000000..bb41b0b3 Binary files /dev/null and b/.doctrees/release_notes/release_1_3_2.doctree differ diff --git a/.doctrees/release_notes/release_1_3_3.doctree b/.doctrees/release_notes/release_1_3_3.doctree new file mode 100644 index 00000000..a83b9d9f Binary files /dev/null and b/.doctrees/release_notes/release_1_3_3.doctree differ diff --git a/.doctrees/release_notes/release_notes.doctree b/.doctrees/release_notes/release_notes.doctree new file mode 100644 index 00000000..ef950fc5 Binary files /dev/null and b/.doctrees/release_notes/release_notes.doctree differ diff --git a/.doctrees/user_guide/adapter_specifics.doctree b/.doctrees/user_guide/adapter_specifics.doctree new file mode 100644 index 00000000..72284305 Binary files /dev/null and b/.doctrees/user_guide/adapter_specifics.doctree differ diff --git a/.doctrees/user_guide/command_line_tools.doctree b/.doctrees/user_guide/command_line_tools.doctree new file mode 100644 index 00000000..082c1aef Binary files /dev/null and b/.doctrees/user_guide/command_line_tools.doctree differ diff --git a/.doctrees/user_guide/remote_access_devices.doctree b/.doctrees/user_guide/remote_access_devices.doctree new file mode 100644 index 00000000..c8d9bb32 Binary files /dev/null and b/.doctrees/user_guide/remote_access_devices.doctree differ diff --git a/.doctrees/user_guide/remote_access_simulation.doctree b/.doctrees/user_guide/remote_access_simulation.doctree new file mode 100644 index 00000000..48898059 Binary files /dev/null and b/.doctrees/user_guide/remote_access_simulation.doctree differ diff --git a/.doctrees/user_guide/usage_with_python.doctree b/.doctrees/user_guide/usage_with_python.doctree new file mode 100644 index 00000000..82b49607 Binary files /dev/null and b/.doctrees/user_guide/usage_with_python.doctree differ diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/_api.html b/_api.html new file mode 100644 index 00000000..89546e74 --- /dev/null +++ b/_api.html @@ -0,0 +1,143 @@ + + + + +
+ + + +
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+import inspect
+from datetime import datetime
+from functools import wraps
+
+from lewis.core.adapters import Adapter
+from lewis.core.devices import InterfaceBase
+from lewis.core.exceptions import (
+ AccessViolationException,
+ LewisException,
+ LimitViolationException,
+)
+from lewis.core.logging import has_log
+from lewis.core.utils import FromOptionalDependency, format_doc_text, seconds_since
+
+# pcaspy might not be available. To make EPICS-based adapters show up
+# in the listed adapters anyway dummy types are created in this case
+# and the failure is postponed to runtime, where a more appropriate
+# LewisException can be raised.
+missing_pcaspy_exception = LewisException(
+ "In order to use EPICS-interfaces, pcaspy must be installed:\n"
+ "\tpip install pcaspy\n"
+ "A fully working installation of EPICS-base is required for this package. "
+ "Please refer to the documentation for advice."
+)
+
+Driver, SimpleServer = FromOptionalDependency("pcaspy", missing_pcaspy_exception).do_import(
+ "Driver", "SimpleServer"
+)
+
+pcaspy_manager = FromOptionalDependency("pcaspy.driver", missing_pcaspy_exception).do_import(
+ "manager"
+)
+
+
+
+[docs]
+class BoundPV:
+ """
+ Class to represent PVs that are bound to an adapter
+
+ This class is very similar to :class:`~lewis.adapters.stream.Func`, in that
+ it is the result of a binding operation between a user-specified :class:`PV`-object
+ and a Device and/or Adapter object. Also, it should rarely be used directly. objects
+ are generated automatically by :class:`EpicsAdapter`.
+
+ The binding happens by supplying a ``target``-object which has an attribute or a property
+ named according to the property-name stored in the PV-object, and a ``meta_target``-object
+ which has an attribute named according to the meta_data_property in PV.
+
+ The properties ``read_only``, ``config``, and ``poll_interval`` simply forward the
+ data of PV, while ``doc`` uses the target object to potentially obtain the property's
+ docstring.
+
+ To get and set the value of the property on the target, the ``value``-property of
+ this class can be used, to get the meta data dict, there's a ``meta``-property.
+
+ :param pv: PV object to bind to target and meta_target.
+ :param target: Object that has an attribute named pv.property.
+ :param meta_target: Object that has an attribute named pv.meta_data_property.
+ """
+
+ def __init__(self, pv, target, meta_target=None) -> None:
+ self._meta_target = meta_target
+ self._target = target
+ self._pv = pv
+
+ @property
+ def value(self):
+ """Value of the bound property on the target."""
+ return getattr(self._target, self._pv.property)
+
+ @value.setter
+ def value(self, new_value) -> None:
+ if self.read_only:
+ raise AccessViolationException(
+ "The property {} is read only.".format(self._pv.property)
+ )
+
+ setattr(self._target, self._pv.property, new_value)
+
+ @property
+ def meta(self):
+ """Value of the bound meta-property on the target."""
+ if not self._pv.meta_data_property or not self._meta_target:
+ return {}
+
+ return getattr(self._meta_target, self._pv.meta_data_property)
+
+ @property
+ def read_only(self):
+ """True if the PV is read-only."""
+ return self._pv.read_only
+
+ @property
+ def config(self):
+ """Config dict passed on to pcaspy-machinery."""
+ return self._pv.config
+
+ @property
+ def poll_interval(self):
+ """Interval at which to update PV in pcaspy."""
+ return self._pv.poll_interval
+
+ @property
+ def doc(self):
+ """Docstring of property on target or override specified on PV-object."""
+ return (
+ self._pv.doc
+ or inspect.getdoc(getattr(type(self._target), self._pv.property, None))
+ or ""
+ )
+
+
+
+
+[docs]
+class PV:
+ """
+ The PV-class is used to declare the EPICS-interface exposed by a sub-class of
+ EpicsAdapter. The ``target_property`` argument specifies which property of the adapter
+ the PV maps to. To make development easier it can also be a part of the exposed
+ device. If the property exists on both the Adapter-subclass and the device, the former
+ has precedence. This is useful for overriding behavior for protocol specific "quirks".
+
+ If the PV should be read only, this needs to be specified via
+ the corresponding parameter. The information about the poll interval is used
+ py EpicsAdapter to update the PV in regular intervals. All other named arguments
+ are forwarded to the pcaspy server's `pvdb`, so it's possible to pass on
+ limits, types, enum-values and so on.
+
+ In case those arguments change at runtime, it's possible to provide ``meta_data_property``,
+ which should contain the name of a property that returns a dict containing these values.
+ For example if limits change:
+
+ .. sourcecode:: Python
+
+ class Interface(EpicsInterface):
+ pvs = {"example": PV("example", meta_data_property="example_meta")}
+
+ @property
+ def example_meta(self):
+ return {
+ "lolim": self.device._example_low_limit,
+ "hilim": self.device._example_high_limit,
+ }
+
+ The PV infos are then updated together with the value, determined by ``poll_interval``.
+
+ In cases where the device is accessed via properties alone, this class provides the possibility
+ to expose methods as PVs. A common use case would be to model a getter:
+
+ .. sourcecode:: Python
+
+ class SomeDevice(Device):
+ def get_example(self):
+ return 42
+
+
+ class Interface(EpicsInterface):
+ pvs = {"example": PV("get_example")}
+
+ It is also possible to model a getter/setter pair, in this case a tuple has to be provided:
+
+ .. sourcecode:: Python
+
+ class SomeDevice(Device):
+ _ex = 40
+
+ def get_example(self):
+ return self._ex + 2
+
+ def set_example(self, new_example):
+ self._ex = new_example - 2
+
+
+ class Interface(EpicsInterface):
+ pvs = {"example": PV(("get_example", "set_example"))}
+
+ Any of the two members in the tuple can be substituted with ``None`` in case it does not apply.
+ Besides method names it is also allowed to provide callables. Valid callables are for example
+ bound methods and free functions, but also lambda expressions and partials.
+
+ There are however restrictions for the supplied functions (be it as method names or directly
+ as callables) with respect to their signature. Getter functions must be callable without any
+ arguments, setter functions must be callable with exactly one argument. The ``self`` of
+ methods does not count towards this.
+
+
+ :param target_property: Property or method name, getter function, tuple of getter/setter.
+ :param poll_interval: Update interval of the PV.
+ :param read_only: Should be True if the PV is read only. If not specified, the PV is
+ read_only if only a getter is supplied.
+ :param meta_data_property: Property or method name, getter function, tuple of getter/setter.
+ :param doc: Description of the PV. If not supplied, docstring of mapped property is used.
+ :param kwargs: Arguments forwarded into pcaspy pvdb-dict.
+ """
+
+ def __init__(
+ self,
+ target_property,
+ poll_interval=1.0,
+ read_only=False,
+ meta_data_property=None,
+ doc=None,
+ **kwargs,
+ ) -> None:
+ self.property = "value"
+ self.read_only = read_only
+ self.poll_interval = poll_interval
+ self.meta_data_property = "meta"
+ self.doc = doc
+ self.config = kwargs
+
+ value = self._get_specification(target_property)
+ meta = self._get_specification(meta_data_property)
+
+ self._specifications = {"value": value, "meta": meta}
+
+
+[docs]
+ def bind(self, *targets):
+ """
+ Tries to bind the PV to one of the supplied targets. Targets are inspected according to
+ the order in which they are supplied.
+
+ :param targets: Objects to inspect from.
+ :return: BoundPV instance with the PV bound to the target property.
+ """
+ self.property = "value"
+ self.meta_data_property = "meta"
+
+ return BoundPV(
+ self,
+ self._get_target(self.property, *targets),
+ self._get_target(self.meta_data_property, *targets),
+ )
+
+
+ def _get_specification(self, spec):
+ """
+ Helper method to create a homogeneous representation of a specified getter or
+ getter/setter pair.
+
+ :param spec: Function specification 'getter', (getter,) or (getter,setter)
+ :return: Harmonized getter/setter specification, (getter, setter)
+ """
+ if spec is None or callable(spec) or isinstance(spec, str):
+ spec = (spec,)
+ if len(spec) == 1:
+ spec = (spec[0], None)
+ return spec
+
+ def _get_target(self, prop, *targets):
+ """
+ The actual target methods are retrieved (possibly from the list of targets) and a
+ wrapper-property is installed on a throwaway type that is specifically created for
+ the purpose of holding this property if necessary. In that case, an instance of this type
+ (with the wrapper-property forwarding calls to the correct target) is returned so
+ that :class:`BoundPV` can do the right thing.
+
+ .. seealso:: :meth:`_create_getter`, :meth:`_create_setter`
+
+ :param prop: Property, is either 'value' or 'meta'.
+ :param targets: List of targets with decreasing priority for finding the wrapped method.
+ :return: Target object to be used by :class:`BoundPV`.
+ """
+
+ if prop is None:
+ return None
+
+ raw_getter, raw_setter = self._specifications.get(prop, (None, None))
+
+ target = None
+
+ if isinstance(raw_getter, str):
+ target = next(
+ (
+ obj
+ for obj in targets
+ if isinstance(getattr(type(obj), raw_getter, None), property)
+ or not callable(getattr(obj, raw_getter, lambda: True))
+ ),
+ None,
+ )
+
+ if target is not None:
+ # If the property is an actual property and has no setter, read_only can be
+ # set to True at this point automatically.
+ target_prop = getattr(type(target), raw_getter, None)
+
+ if prop == "value" and isinstance(target_prop, property) and target_prop.fset is None:
+ self.read_only = True
+
+ # Now the target does not need to be constructed, property or meta_data_property
+ # needs to change.
+ setattr(
+ self,
+ "property" if prop == "value" else "meta_data_property",
+ raw_getter,
+ )
+ return target
+
+ getter = self._create_getter(raw_getter, *targets)
+ setter = self._create_setter(raw_setter, *targets)
+
+ if getter is None and setter is None:
+ return None
+
+ if prop == "value" and setter is None:
+ self.read_only = True
+
+ return type(prop, (object,), {prop: property(getter, setter)})()
+
+ def _create_getter(self, func, *targets):
+ """
+ Returns a function wrapping the supplied function. The returned wrapper can be used as the
+ getter in a property definition. Raises a RuntimeError if the signature of the supplied
+ function is not compatible with the getter-concept (no arguments except self).
+
+ :param func: Callable or name of method on one object in targets.
+ :param targets: List of targets with decreasing priority for finding func.
+ :return: Getter function for constructing a wrapper-property.
+ """
+ if not func:
+ return None
+
+ final_callable = self._get_callable(func, *targets)
+
+ if not self._function_has_n_args(final_callable, 0):
+ raise RuntimeError(
+ "The function '{}' does not look like a getter function. A valid getter "
+ "function has no arguments that do not have a default. The self-argument of "
+ "methods does not count towards that number.".format(final_callable.__name__)
+ )
+
+ @wraps(final_callable)
+ def getter(obj):
+ return final_callable()
+
+ return getter
+
+ def _create_setter(self, func, *targets):
+ """
+ Returns a function wrapping the supplied function. The returned wrapper can be used as the
+ setter in a property definition. Raises a RuntimeError if the signature of the supplied
+ function is not compatible with the setter-concept (exactly one argument except self).
+
+ :param func: Callable or name of method on one object in targets.
+ :param targets: List of targets with decreasing priority for finding func.
+ :return: Setter function for constructing a wrapper-property or ``None``.
+ """
+ if not func:
+ return None
+
+ func = self._get_callable(func, *targets)
+
+ if not self._function_has_n_args(func, 1):
+ raise RuntimeError(
+ "The function '{}' does not look like a setter function. A valid setter "
+ "function has exactly one argument without a default. The self-argument of "
+ "methods does not count towards that number.".format(func.__name__)
+ )
+
+ def setter(obj, value) -> None:
+ func(value)
+
+ return setter
+
+ def _get_callable(self, func, *targets):
+ """
+ If func is already a callable, it is returned directly. If it's a string, it is assumed
+ to be a method on one of the objects supplied in targets and that is returned. If no
+ method with the specified name is found, an AttributeError is raised.
+
+ :param func: Callable or name of method on one object in targets.
+ :param targets: List of targets with decreasing priority for finding func.
+ :return: Callable.
+ """
+ if not callable(func):
+ func_name = func
+ func = next((getattr(obj, func, None) for obj in targets if func in dir(obj)), None)
+
+ if not func:
+ raise AttributeError(
+ "No method with the name '{}' could be found on any of the target objects "
+ "(device, interface). Please check the spelling.".format(func_name)
+ )
+
+ return func
+
+ def _function_has_n_args(self, func, n):
+ """
+ Returns true if func has n arguments. Arguments with default and self for
+ methods are not considered.
+ """
+ if inspect.ismethod(func):
+ n += 1
+
+ argspec = inspect.getargspec(func)
+ defaults = argspec.defaults or ()
+
+ return len(argspec.args) - len(defaults) == n
+
+
+
+@has_log
+class PropertyExposingDriver(Driver):
+ def __init__(self, interface, device_lock) -> None:
+ super(PropertyExposingDriver, self).__init__()
+
+ self._interface = interface
+ self._device_lock = device_lock
+ self._set_logging_context(interface)
+
+ self._timers = {k: 0.0 for k in self._interface.bound_pvs.keys()}
+ self._last_update_call = None
+
+ def write(self, pv, value) -> bool:
+ self.log.debug("PV put request: %s=%s", pv, value)
+
+ pv_object = self._interface.bound_pvs.get(pv)
+
+ if not pv_object:
+ return False
+
+ try:
+ with self._device_lock:
+ pv_object.value = value
+ self.setParam(pv, pv_object.value)
+
+ return True
+ except LimitViolationException as e:
+ self.log.warning(
+ "Rejected writing value %s to PV %s due to limit " "violation. %s",
+ value,
+ pv,
+ e,
+ )
+ except AccessViolationException:
+ self.log.warning(
+ "Rejected writing value %s to PV %s due to access " "violation, PV is read-only.",
+ value,
+ pv,
+ )
+
+ return False
+
+ def _get_param_info(self, pv, meta_keys):
+ """
+ Get PV info fields from pcaspy's "manager" object. This function returns a dictionary
+ with info/value pairs, where each entry of meta_keys results in a dictionary entry if
+ pcaspy's PVInfo-object has such an attribute. Attributes that do not exist are ignored.
+ Valid attributes are the same as specified in the ``pvdb``-argument that
+
+ :param pv: PV base name
+ :param meta_keys: List of keys for what information to obtain
+ :return:
+ """
+ # TODO: Submit upstream patch to make this method available in base class
+ pv = pcaspy_manager.pvs[self.port][pv]
+
+ info_dict = {}
+ for key in meta_keys:
+ if hasattr(pv.info, key):
+ info_dict[key] = getattr(pv.info, key)
+
+ return info_dict
+
+ def process_pv_updates(self, force=False) -> None:
+ """
+ Update PV values that have changed for PVs that are due to update according to their
+ respective poll interval timers.
+
+ :param force: If True, will force updates to all PVs regardless of timers.
+ """
+ dt = seconds_since(self._last_update_call or datetime.now())
+
+ # Cache details of PVs that need to update
+ value_updates = []
+ meta_updates = []
+
+ with self._device_lock:
+ for pv, pv_object in self._interface.bound_pvs.items():
+ self._timers[pv] = self._timers.get(pv, 0.0) + dt
+ if self._timers[pv] >= pv_object.poll_interval or force:
+ try:
+ if self.getParam(pv) != pv_object.value or force:
+ value_updates.append((pv, pv_object.value))
+
+ pv_meta = pv_object.meta
+ if self._get_param_info(pv, pv_meta.keys()) != pv_meta or force:
+ meta_updates.append((pv, pv_meta))
+
+ except (AttributeError, TypeError):
+ self.log.exception("An error occurred while updating PV %s.", pv)
+ finally:
+ self._timers[pv] = 0.0
+
+ self._process_value_updates(value_updates)
+ self._process_meta_updates(meta_updates)
+
+ self._last_update_call = datetime.now()
+
+ def _process_value_updates(self, updates) -> None:
+ if updates:
+ update_log = []
+ for pv, value in updates:
+ self.setParam(pv, value)
+ update_log.append("{}={}".format(pv, value))
+
+ self.log.info("Processed PV updates: %s", ", ".join(update_log))
+
+ # Calling this manually is only required for values, not for meta
+ self.updatePVs()
+
+ def _process_meta_updates(self, updates) -> None:
+ if updates:
+ update_log = []
+ for pv, info in updates:
+ self.setParamInfo(pv, info)
+ update_log.append("{}={}".format(pv, info))
+
+ self.log.info("Processed PV-info updates: %s", ", ".join(update_log))
+
+
+
+[docs]
+class EpicsAdapter(Adapter):
+ """
+ This adapter provides ChannelAccess server functionality through the pcaspy module.
+
+ It's possible to configure the prefix for the PVs provided by this adapter. The
+ corresponding key in the ``options`` dictionary is called ``prefix``:
+
+ .. sourcecode:: Python
+
+ options = {"prefix": "PVPREFIX:"}
+
+ :param options: Dictionary with options.
+ """
+
+ default_options = {"prefix": ""}
+
+ def __init__(self, options=None) -> None:
+ super(EpicsAdapter, self).__init__(options)
+
+ self._server = None
+ self._driver = None
+
+ @property
+ def documentation(self):
+ pvs = []
+
+ for name, pv in self.interface.bound_pvs.items():
+ complete_name = self._options.prefix + name
+
+ data_type = pv.config.get("type", "float")
+ read_only_tag = ", read only" if pv.read_only else ""
+
+ pvs.append(
+ "{} ({}{}):\n{}".format(
+ complete_name, data_type, read_only_tag, format_doc_text(pv.doc)
+ )
+ )
+
+ return "\n\n".join([inspect.getdoc(self.interface) or "", "PVs\n==="] + pvs)
+
+
+[docs]
+ def start_server(self) -> None:
+ """
+ Creates a pcaspy-server.
+
+ .. note::
+
+ The server does not process requests unless :meth:`handle` is called regularly.
+ """
+ if self._server is None:
+ self._server = SimpleServer()
+ self._server.createPV(
+ prefix=self._options.prefix,
+ pvdb={k: v.config for k, v in self.interface.bound_pvs.items()},
+ )
+ self._driver = PropertyExposingDriver(
+ interface=self.interface, device_lock=self.device_lock
+ )
+ self._driver.process_pv_updates(force=True)
+
+ self.log.info(
+ "Started serving PVs: %s",
+ ", ".join((self._options.prefix + pv for pv in self.interface.bound_pvs.keys())),
+ )
+
+
+
+
+
+ @property
+ def is_running(self):
+ return self._server is not None
+
+
+[docs]
+ def handle(self, cycle_delay=0.1) -> None:
+ """
+ Call this method to spend about ``cycle_delay`` seconds processing
+ requests in the pcaspy server. Under load, for example when running ``caget`` at a
+ high frequency, the actual time spent in the method may be much shorter. This effect
+ is not corrected for.
+
+ :param cycle_delay: Approximate time to be spent processing requests in pcaspy server.
+ """
+ if self._server is not None:
+ self._server.process(cycle_delay)
+ self._driver.process_pv_updates()
+
+
+
+
+
+[docs]
+class EpicsInterface(InterfaceBase):
+ """
+ Inheriting from this class provides an EPICS-interface to a device for use with
+ :class:`EpicsAdapter`. In the simplest case all that is required is to inherit
+ from this class and override the ``pvs``-member. It should be a dictionary
+ that contains PV-names (without prefix) as keys and instances of PV as
+ values. The prefix is handled by ``EpicsAdapter``.
+
+ For a simple device with two properties, speed and position, the first of which
+ should be read-only, it's enough to define the following:
+
+ .. sourcecode:: Python
+
+ class SimpleDeviceEpicsInterface(EpicsInterface):
+ pvs = {"VELO": PV("speed", read_only=True), "POS": PV("position", lolo=0, hihi=100)}
+
+ For more complex behavior, the interface could contain properties that do not
+ exist in the device itself. If the device should also have a PV called STOP
+ that "stops the device", the interface could look like this:
+
+ .. sourcecode:: Python
+
+ class SimpleDeviceEpicsInterface(EpicsInterface):
+ pvs = {
+ "VELO": PV("speed", read_only=True),
+ "POS": PV("position", lolo=0, hihi=100),
+ "STOP": PV("stop", type="int"),
+ }
+
+ @property
+ def stop(self):
+ return 0
+
+ @stop.setter
+ def stop(self, value):
+ if value == 1:
+ self.device.halt()
+
+ Even though the device does *not* have a property called ``stop`` (but a method called
+ ``halt``), issuing the command
+
+ ::
+
+ $ caput STOP 1
+
+ will achieve the desired behavior, because ``EpicsInterface`` merges the properties
+ of the device into ``SimpleDeviceEpicsInterface`` itself, so that it is does not
+ matter whether the specified property in PV exists in the device or the adapter.
+
+ The intention of this design is to keep device classes small and free of
+ protocol specific stuff, such as in the case above where stopping a device
+ via EPICS might involve writing a value to a PV, whereas other protocols may
+ offer an RPC-way of achieving the same thing.
+ """
+
+ protocol = "epics"
+ pvs = None
+
+ def __init__(self) -> None:
+ super(EpicsInterface, self).__init__()
+ self.bound_pvs = None
+
+ @property
+ def adapter(self):
+ return EpicsAdapter
+
+ def _bind_device(self) -> None:
+ """
+ This method transforms the ``self.pvs`` dict of :class:`PV` objects ``self.bound_pvs``,
+ a dict of :class:`BoundPV` objects, the keys are always the PV-names that are exposed
+ via ChannelAccess.
+
+ In the transformation process, the method tries to find whether the attribute specified by
+ PV's ``property`` (and ``meta_data_property``) is part of the internally stored device
+ or the interface and constructs a BoundPV, which acts as a forwarder to the appropriate
+ objects.
+ """
+ self.bound_pvs = {}
+
+ for pv_name, pv in self.pvs.items():
+ try:
+ self.bound_pvs[pv_name] = pv.bind(self, self.device)
+ except (AttributeError, RuntimeError) as e:
+ self.log.debug(
+ "An exception was caught during the binding step of PV '%s'.",
+ pv_name,
+ exc_info=e,
+ )
+ raise LewisException(
+ "The binding step for PV '{}' failed, please check the interface-"
+ "definition or contact the device author. More information is "
+ "available with debug-level logging (-o debug).".format(pv_name)
+ )
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module provides components to expose a Device via a Modbus-interface. The following resources
+were used as guidelines and references for implementing the protocol:
+
+ - http://www.modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
+ - http://www.modbus.org/docs/Modbus_Messaging_Implementation_Guide_V1_0b.pdf
+ - https://github.com/sourceperl/pyModbusTCP
+ - https://github.com/bashwork/pymodbus
+
+.. note::
+
+ For an example how Modbus can be used in the current implementation, please look
+ at lewis/examples/modbus_device.
+"""
+
+import asyncore
+import socket
+import struct
+from copy import deepcopy
+from math import ceil
+
+from lewis.core.adapters import Adapter
+from lewis.core.devices import InterfaceBase
+from lewis.core.logging import has_log
+
+
+
+[docs]
+class ModbusDataBank:
+ """
+ Preliminary DataBank implementation for Modbus.
+
+ This is a very generic implementation of a databank for Modbus. It's meant to set the
+ groundwork for future implementations. Only derived classes should be instantiated, not
+ this class directly. The signature of this __init__ method is subject to change.
+
+ :param kwargs: Configuration
+ """
+
+ def __init__(self, **kwargs) -> None:
+ self._data = kwargs["data"]
+ self._start_addr = kwargs["start_addr"]
+
+
+[docs]
+ def get(self, addr, count):
+ """
+ Read list of ``count`` values at ``addr`` memory location in DataBank.
+
+ :param addr: Address to read from
+ :param count: Number of entries to retrieve
+ :return: list of entry values
+ :except IndexError: Raised if address range falls outside valid range
+ """
+ addr -= self._start_addr
+ data = self._data[addr : addr + count]
+ if len(data) != count:
+ addr += self._start_addr
+ raise IndexError("Invalid address range [{:#06x} - {:#06x}]".format(addr, addr + count))
+ return data
+
+
+
+[docs]
+ def set(self, addr, values) -> None:
+ """
+ Write list ``values`` to ``addr`` memory location in DataBank.
+
+ :param addr: Address to write to
+ :param values: list of values to write
+ :except IndexError: Raised if address range falls outside valid range
+ """
+ addr -= self._start_addr
+ end = addr + len(values)
+ if not 0 <= addr <= end <= len(self._data):
+ addr += self._start_addr
+ raise IndexError(
+ "Invalid address range [{:#06x} - {:#06x}]".format(addr, addr + len(values))
+ )
+ self._data[addr:end] = values
+
+
+
+
+
+[docs]
+class ModbusBasicDataBank(ModbusDataBank):
+ """
+ A basic ModbusDataBank instance.
+
+ This type of DataBank simply serves as a memory space for Modbus requests to read from and
+ write to. It does not support binding addresses to attributes or functions of the device
+ or interface. Example usage:
+
+ .. sourcecode:: Python
+
+ di = ModbusBasicDataBank(False, 0x1000, 0x1FFF)
+
+ :param default_value: Value to initialize memory with
+ :param start_addr: First valid address
+ :param last_addr: Last valid address
+ """
+
+ def __init__(self, default_value=0, start_addr=0x0000, last_addr=0xFFFF) -> None:
+ super(ModbusBasicDataBank, self).__init__(
+ start_addr=start_addr, data=[default_value] * (last_addr - start_addr + 1)
+ )
+
+
+
+
+[docs]
+class ModbusDataStore:
+ """Convenience struct to hold the four types of DataBanks in Modbus"""
+
+ def __init__(self, di=None, co=None, ir=None, hr=None) -> None:
+ self.di = di
+ self.co = co
+ self.ir = ir
+ self.hr = hr
+
+
+
+
+[docs]
+class MBEX:
+ """Modbus standard exception codes"""
+
+ ILLEGAL_FUNCTION = 0x01
+ DATA_ADDRESS = 0x02
+ DATA_VALUE = 0x03
+ SLAVE_DEVICE_FAILURE = 0x04
+ ACKNOWLEDGE = 0x05
+ SLAVE_DEVICE_BUSY = 0x06
+ MEMORY_PARITY_ERROR = 0x08
+ GATEWAY_PATH_UNAVAILABLE = 0x0A
+ GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND = 0x0B
+
+
+
+
+[docs]
+class ModbusTCPFrame:
+ """
+ This class models a frame of the Modbus TCP protocol.
+
+ It may be a request, a response or an exception. Typically, requests are constructed using the
+ init method, while responses and exceptions are constructed by called create_request or
+ create_exception on an instance that is a request.
+
+ Note that data from the passed in bytearray stream is consumed. That is, bytes will be removed
+ from the front of the bytearray if construction is successful.
+
+ :param stream: bytearray to consume data from to construct this frame.
+ :except EOFError: Not enough data for complete frame; no data consumed.
+ """
+
+ def __init__(self, stream=None) -> None:
+ self.transaction_id = 0
+ self.protocol_id = 0
+ self.length = 2
+ self.unit_id = 0
+ self.fcode = 0
+ self.data = bytearray()
+
+ if stream is not None:
+ self.from_bytearray(stream)
+
+
+[docs]
+ def from_bytearray(self, stream) -> None:
+ """
+ Constructs this frame from input data stream, consuming as many bytes as necessary from
+ the beginning of the stream.
+
+ If stream does not contain enough data to construct a complete modbus frame, an EOFError
+ is raised and no data is consumed.
+
+ :param stream: bytearray to consume data from to construct this frame.
+ :except EOFError: Not enough data for complete frame; no data consumed.
+ """
+ fmt = ">HHHBB"
+ size_header = struct.calcsize(fmt)
+ if len(stream) < size_header:
+ raise EOFError
+
+ (
+ self.transaction_id,
+ self.protocol_id,
+ self.length,
+ self.unit_id,
+ self.fcode,
+ ) = struct.unpack(fmt, bytes(stream[:size_header]))
+
+ size_total = size_header + self.length - 2
+ if len(stream) < size_total:
+ raise EOFError
+
+ self.data = stream[size_header:size_total]
+ del stream[:size_total]
+
+
+
+[docs]
+ def to_bytearray(self):
+ """
+ Convert this frame into its bytearray representation.
+
+ :return: bytearray representation of this frame.
+ """
+ header = bytearray(
+ struct.pack(
+ ">HHHBB",
+ self.transaction_id,
+ self.protocol_id,
+ self.length,
+ self.unit_id,
+ self.fcode,
+ )
+ )
+ return header + self.data
+
+
+
+[docs]
+ def is_valid(self):
+ """
+ Check integrity and validity of this frame.
+
+ :return: bool True if this frame is structurally valid.
+ """
+ conditions = [
+ self.protocol_id == 0, # Modbus always uses protocol 0
+ 2 <= self.length <= 260, # Absolute length limits
+ len(self.data) == self.length - 2, # Total length matches data length
+ ]
+ return all(conditions)
+
+
+
+[docs]
+ def create_exception(self, code):
+ """
+ Create an exception frame based on this frame.
+
+ :param code: Modbus exception code to use for this exception
+ :return: ModbusTCPFrame instance that represents an exception
+ """
+ frame = deepcopy(self)
+ frame.length = 3
+ frame.fcode += 0x80
+ frame.data = bytearray(chr(code))
+ return frame
+
+
+
+[docs]
+ def create_response(self, data=None):
+ """
+ Create a response frame based on this frame.
+
+ :param data: Data section of response as bytearray. If None, request data section is kept.
+ :return: ModbusTCPFrame instance that represents a response
+ """
+ frame = deepcopy(self)
+ if data is not None:
+ frame.data = data
+ frame.length = 2 + len(frame.data)
+ return frame
+
+
+
+
+
+[docs]
+@has_log
+class ModbusProtocol:
+ """
+ This class implements the Modbus TCP Protocol.
+
+ The user of this class should provide a ModbusDataStore instance that will be used to
+ fulfill read and write requests, and a callable `sender` which accepts one bytearray
+ parameter. The `sender` will be called whenever a response frame is generated, with a
+ bytearray containing the response frame as the parameter.
+
+ Processing occurs when the user calls ModbusProtocol.process(), passing in the raw frame
+ data to process as a bytearray. The data may include multiple frames and partial frame
+ fragments. Any data that could not be processed (due to incomplete frames) is buffered for
+ the next call to process.
+
+ :param sender: callable that accepts one bytearray parameter, called to send responses.
+ :param datastore: ModbusDataStore instance to reference when processing requests
+ """
+
+ def __init__(self, sender, datastore) -> None:
+ self._buffer = bytearray()
+ self._datastore = datastore
+ self._send = lambda req: sender(req.to_bytearray())
+
+ # Lookup table to handle requests as per Modbus Application Protocol v1.1b3, Section 6.
+ self._fcode_handler_map = {
+ 0x01: self._handle_read_coils,
+ 0x02: self._handle_read_discrete_inputs,
+ 0x03: self._handle_read_holding_registers,
+ 0x04: self._handle_read_input_registers,
+ 0x05: self._handle_write_single_coil,
+ 0x06: self._handle_write_single_register,
+ 0x0F: self._handle_write_multiple_coils,
+ 0x10: self._handle_write_multiple_registers,
+ }
+
+
+[docs]
+ def process(self, data, device_lock) -> None:
+ """
+ Process as much of given data as possible.
+
+ Any remainder, in case there is an incomplete frame at the end, is stored so that
+ processing may continue where it left off when more data is provided.
+
+ :param data: Incoming byte data. Must be compatible with bytearray.
+ :param device_lock: threading.Lock instance that is acquired for device interaction.
+ """
+ self._buffer.extend(bytearray(data))
+
+ with device_lock:
+ for request in self._buffered_requests():
+ self.log.debug(
+ "Request: %s",
+ str(["{:#04x}".format(c) for c in request.to_bytearray()]),
+ )
+
+ handler = self._get_handler(request.fcode)
+ response = handler(request)
+
+ self.log.debug(
+ "Response: %s",
+ str(["{:#04x}".format(c) for c in response.to_bytearray()]),
+ )
+
+ self._send(response)
+
+
+ def _buffered_requests(self):
+ """Generator to yield all complete modbus requests in the internal buffer"""
+ try:
+ while True:
+ # ModbusTCPFrame constructor consumes bytes from front of buffer
+ yield ModbusTCPFrame(self._buffer)
+ except EOFError:
+ pass
+
+ def _get_handler(self, fcode):
+ """
+ Get an appropriate handler function for given Function Code.
+
+ Will always return a valid handler function. But, if the Function Code is invalid or not
+ supported, the handler function will merely return an ILLEGAL_FUNCTION exception frame.
+
+ :param fcode: int Function Code which needs to be handled
+ :return: callable which takes one request frame parameter and returns a response frame
+ """
+ return self._fcode_handler_map.get(fcode, self._illegal_function_exception)
+
+ def _illegal_function_exception(self, request):
+ """Log and return an illegal function code exception"""
+ self.log.error("Unsupported Function Code: {0} ({0:#04x})".format(request.fcode))
+ return request.create_exception(MBEX.ILLEGAL_FUNCTION)
+
+ def _handle_read_coils(self, request):
+ """
+ Handle request as per Modbus Application Protocol v1.1b3:
+ Section 6.1 - (0x01) Read Coils
+
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ return self._do_read_bits(self._datastore.co, request)
+
+ def _handle_read_discrete_inputs(self, request):
+ """
+ Handle request as per Modbus Application Protocol v1.1b3:
+ Section 6.2 - (0x02) Read Discrete Inputs
+
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ return self._do_read_bits(self._datastore.di, request)
+
+ def _do_read_bits(self, databank, request):
+ """
+ Shared handler for FC 0x01 and FC 0x02.
+
+ :param databank: DataBank to execute against (di or co)
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ addr, count = struct.unpack(">HH", bytes(request.data))
+
+ if not 0x0001 <= count <= 0x07D0:
+ return request.create_exception(MBEX.DATA_VALUE)
+
+ try:
+ bits = databank.get(addr, count)
+ bits = [bool(bit) for bit in bits]
+ except IndexError:
+ return request.create_exception(MBEX.DATA_ADDRESS)
+
+ # Bits to bytes: LSB -> MSB, first byte -> last byte
+ byte_count = int(ceil(len(bits) / 8))
+ byte_list = bytearray(byte_count)
+ for i, bit in enumerate(bits):
+ byte_list[i // 8] |= bit << i % 8
+
+ # Construct response
+ data = struct.pack(">B%dB" % byte_count, byte_count, *list(byte_list))
+ return request.create_response(data)
+
+ def _handle_read_holding_registers(self, request):
+ """
+ Handle request as per Modbus Application Protocol v1.1b3:
+ Section 6.3 - (0x03) Read Holding Registers
+
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ return self._do_read_registers(self._datastore.hr, request)
+
+ def _handle_read_input_registers(self, request):
+ """
+ Handle request as per Modbus Application Protocol v1.1b3:
+ Section 6.4 - (0x04) Read Input Registers
+
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ return self._do_read_registers(self._datastore.ir, request)
+
+ def _do_read_registers(self, databank, request):
+ """
+ Shared handler for FC 0x03 and FC 0x04.
+
+ :param databank: DataBank to execute against (ir or hr)
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ addr, count = struct.unpack(">HH", bytes(request.data))
+
+ if not 0x0001 <= count <= 0x007D:
+ return request.create_exception(MBEX.DATA_VALUE)
+
+ try:
+ words = databank.get(addr, count)
+ words = [word & 0xFFFF for word in words]
+ except IndexError:
+ return request.create_exception(MBEX.DATA_ADDRESS)
+
+ # Construct response
+ data = struct.pack(">B%dH" % len(words), len(words) * 2, *words)
+ return request.create_response(data)
+
+ def _handle_write_single_coil(self, request):
+ """
+ Handle request as per Modbus Application Protocol v1.1b3:
+ Section 6.5 - (0x05) Write Single Coil
+
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ addr, value = struct.unpack(">HH", bytes(request.data))
+ value = {0x0000: False, 0xFF00: True}.get(value, None)
+
+ if value is None:
+ return request.create_exception(MBEX.DATA_VALUE)
+
+ try:
+ self._datastore.co.set(addr, [value])
+ except IndexError:
+ return request.create_exception(MBEX.DATA_ADDRESS)
+
+ # Respond to confirm
+ return request.create_response()
+
+ def _handle_write_single_register(self, request):
+ """
+ Handle request as per Modbus Application Protocol v1.1b3:
+ Section 6.6 - (0x06) Write Single Register
+
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ addr, value = struct.unpack(">HH", bytes(request.data))
+
+ try:
+ self._datastore.hr.set(addr, [value])
+ except IndexError:
+ return request.create_exception(MBEX.DATA_ADDRESS)
+
+ # Respond to confirm
+ return request.create_response()
+
+ def _handle_write_multiple_coils(self, request):
+ """
+ Handle request as per Modbus Application Protocol v1.1b3:
+ Section 6.11 - (0x0F) Write Multiple Coils
+
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ addr, bit_count, byte_count = struct.unpack(">HHB", bytes(request.data[:5]))
+ data = request.data[5:]
+
+ if not 0x0001 <= bit_count <= 0x07B0 or byte_count != ceil(bit_count / 8):
+ return request.create_exception(MBEX.DATA_VALUE)
+
+ # Bytes to bits: first byte -> last byte, LSB -> MSB
+ bits = [False] * bit_count
+ for i in range(bit_count):
+ bits[i] = bool(data[i // 8] & (1 << i % 8))
+
+ try:
+ self._datastore.co.set(addr, bits)
+ except IndexError:
+ return request.create_exception(MBEX.DATA_ADDRESS)
+
+ # Respond to confirm
+ return request.create_response(request.data[:4])
+
+ def _handle_write_multiple_registers(self, request):
+ """
+ Handle request as per Modbus Application Protocol v1.1b3:
+ Section 6.12 - (0x10) Write Multiple registers
+
+ :param request: ModbusTCPFrame containing the request
+ :return: ModbusTCPFrame response to the request
+ """
+ addr, reg_count, byte_count = struct.unpack(">HHB", bytes(request.data[:5]))
+ data = request.data[5:]
+
+ if not 0x0001 <= reg_count <= 0x007B or byte_count != reg_count * 2:
+ return request.create_exception(MBEX.DATA_VALUE)
+
+ try:
+ words = list(struct.unpack(">%dH" % reg_count, data))
+ self._datastore.hr.set(addr, words)
+ except IndexError:
+ return request.create_exception(MBEX.DATA_ADDRESS)
+
+ # Respond to confirm
+ return request.create_response(request.data[:4])
+
+
+
+@has_log
+class ModbusHandler(asyncore.dispatcher_with_send):
+ def __init__(self, sock, interface, server) -> None:
+ asyncore.dispatcher_with_send.__init__(self, sock=sock)
+ self._datastore = ModbusDataStore(interface.di, interface.co, interface.ir, interface.hr)
+ self._modbus = ModbusProtocol(self.send, self._datastore)
+ self._server = server
+
+ self._set_logging_context(interface)
+ self.log.info("Client connected from %s:%s", *sock.getpeername())
+
+ def handle_read(self) -> None:
+ data = self.recv(8192)
+ self._modbus.process(data, self._server.device_lock)
+
+ def handle_close(self) -> None:
+ self.log.info("Closing connection to client %s:%s", *self.socket.getpeername())
+ self._server.remove_handler(self)
+ self.close()
+
+
+@has_log
+class ModbusServer(asyncore.dispatcher):
+ def __init__(self, host, port, interface, device_lock) -> None:
+ asyncore.dispatcher.__init__(self)
+ self.device_lock = device_lock
+ self.interface = interface
+ self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.set_reuse_addr()
+ self.bind((host, port))
+ self.listen(5)
+
+ self._set_logging_context(interface)
+ self.log.info("Listening on %s:%s", host, port)
+
+ self._accepted_connections = []
+
+ def handle_accept(self) -> None:
+ pair = self.accept()
+ if pair is not None:
+ sock, _ = pair
+ handler = ModbusHandler(sock, self.interface, self)
+ self._accepted_connections.append(handler)
+
+ def remove_handler(self, handler) -> None:
+ self._accepted_connections.remove(handler)
+
+ def handle_close(self) -> None:
+ self.log.info("Shutting down server, closing all remaining client connections.")
+
+ for handler in self._accepted_connections:
+ handler.close()
+ self._accepted_connections = []
+ self.close()
+
+
+
+[docs]
+class ModbusAdapter(Adapter):
+ default_options = {"bind_address": "0.0.0.0", "port": 502}
+
+ def __init__(self, options=None) -> None:
+ super(ModbusAdapter, self).__init__(options)
+ self._server = None
+
+
+[docs]
+ def start_server(self) -> None:
+ self._server = ModbusServer(
+ self._options.bind_address,
+ self._options.port,
+ self.interface,
+ self.device_lock,
+ )
+
+
+
+[docs]
+ def stop_server(self) -> None:
+ if self._server is not None:
+ self._server.close()
+ self._server = None
+
+
+ @property
+ def is_running(self):
+ return self._server is not None
+
+
+
+
+
+
+
+[docs]
+class ModbusInterface(InterfaceBase):
+ protocol = "modbus"
+ di = None
+ co = None
+ ir = None
+ hr = None
+
+ @property
+ def adapter(self):
+ return ModbusAdapter
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+import asynchat
+import asyncore
+import inspect
+import re
+import socket
+from typing import NoReturn
+
+from scanf import scanf_compile
+
+from lewis.core.adapters import Adapter
+from lewis.core.devices import InterfaceBase
+from lewis.core.logging import has_log
+from lewis.core.utils import format_doc_text
+
+
+
+[docs]
+@has_log
+class StreamHandler(asynchat.async_chat):
+ def __init__(self, sock, target, stream_server) -> None:
+ asynchat.async_chat.__init__(self, sock=sock)
+ self.set_terminator(target.in_terminator.encode())
+ self._readtimeout = target.readtimeout
+ self._readtimer = 0
+ self._target = target
+ self._buffer = []
+
+ self._stream_server = stream_server
+ self._target.handler = self
+
+ self._set_logging_context(target)
+ self.log.info("Client connected from %s:%s", *sock.getpeername())
+
+ def process(self, msec) -> None:
+ if not self._buffer:
+ return
+
+ if self._readtimer >= self._readtimeout and self._readtimeout != 0:
+ if not self.get_terminator():
+ # If no terminator is set, this timeout is the terminator
+ self.found_terminator()
+ else:
+ self._readtimer = 0
+ request = self._get_request()
+ with self._stream_server.device_lock:
+ error = RuntimeError("ReadTimeout while waiting for command terminator.")
+ reply = self._handle_error(request, error)
+ self._send_reply(reply)
+
+ if self._buffer:
+ self._readtimer += msec
+
+ def collect_incoming_data(self, data) -> None:
+ self._buffer.append(data)
+ self._readtimer = 0
+
+ def _get_request(self):
+ request = b"".join(self._buffer)
+ self._buffer = []
+ self.log.debug("Got request %s", request)
+ return request
+
+ def _push(self, reply) -> None:
+ try:
+ if isinstance(reply, str):
+ reply = reply.encode()
+ out_terminator = (
+ self._target.out_terminator.encode()
+ if isinstance(self._target.out_terminator, str)
+ else self._target.out_terminator
+ )
+ self.push(reply + out_terminator)
+ except TypeError as e:
+ self.log.error("Problem creating reply, type error {}!".format(e))
+
+ def _send_reply(self, reply) -> None:
+ if reply is not None:
+ self.log.debug("Sending reply %s", reply)
+ self._push(reply)
+
+ def _handle_error(self, request, error):
+ self.log.debug("Error while processing request", exc_info=error)
+ return self._target.handle_error(request, error)
+
+ def found_terminator(self) -> None:
+ self._readtimer = 0
+
+ request = self._get_request()
+
+ with self._stream_server.device_lock:
+ try:
+ cmd = next(
+ (cmd for cmd in self._target.bound_commands if cmd.can_process(request)),
+ None,
+ )
+
+ if cmd is None:
+ raise RuntimeError("None of the device's commands matched.")
+
+ self.log.info(
+ "Processing request %s using command %s",
+ request,
+ cmd.matcher.pattern,
+ )
+
+ reply = cmd.process_request(request)
+ except Exception as error:
+ reply = self._handle_error(request, error)
+
+ self._send_reply(reply)
+
+ def unsolicited_reply(self, reply) -> None:
+ self.log.debug("Sending unsolicited reply %s", reply)
+ self._push(reply)
+
+ def handle_close(self) -> None:
+ self.log.info("Closing connection to client %s:%s", *self.socket.getpeername())
+ self._stream_server.remove_handler(self)
+ asynchat.async_chat.handle_close(self)
+
+
+
+@has_log
+class StreamServer(asyncore.dispatcher):
+ def __init__(self, host, port, target, device_lock) -> None:
+ asyncore.dispatcher.__init__(self)
+ self.target = target
+ self.device_lock = device_lock
+ self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.set_reuse_addr()
+ self.bind((host, port))
+ self.listen(5)
+
+ self._set_logging_context(target)
+ self.log.info("Listening on %s:%s", host, port)
+
+ self._accepted_connections = []
+
+ def handle_accept(self) -> None:
+ pair = self.accept()
+ if pair is not None:
+ sock, addr = pair
+ handler = StreamHandler(sock, self.target, self)
+
+ self._accepted_connections.append(handler)
+
+ def remove_handler(self, handler) -> None:
+ self._accepted_connections.remove(handler)
+
+ def close(self) -> None:
+ # As this is an old style class, the base class method must
+ # be called directly. This is important to still perform all
+ # the teardown-work that asyncore.dispatcher does.
+ self.log.info("Shutting down server, closing all remaining client connections.")
+ asyncore.dispatcher.close(self)
+
+ # But in addition, close all open sockets and clear the connection list.
+ for handler in self._accepted_connections:
+ handler.close()
+
+ self._accepted_connections = []
+
+ def process(self, msec) -> None:
+ for handler in self._accepted_connections:
+ handler.process(msec)
+
+
+
+[docs]
+class PatternMatcher:
+ """
+ This class defines an interface for general command-matchers that use any kind of
+ technique to match a certain request in string form. It is used by :class:`Func` to check
+ whether a request can be processed using a function and to extract any function arguments.
+
+ Sub-classes must implement all defined abstract methods/properties.
+
+ .. seealso::
+
+ :class:`regex`, :class:`scanf` are concrete implementations of this class.
+ """
+
+ def __init__(self, pattern) -> None:
+ self._pattern = pattern
+
+ @property
+ def pattern(self):
+ """The pattern definition used for matching a request."""
+ return self._pattern
+
+ @property
+ def arg_count(self) -> NoReturn:
+ """Number of arguments that are matched in a request."""
+ raise NotImplementedError("The arg_count property must be implemented.")
+
+ @property
+ def argument_mappings(self) -> NoReturn:
+ """Mapping functions that can be applied to the arguments returned by :meth:`match`."""
+ raise NotImplementedError("The argument_mappings property must be implemented.")
+
+
+[docs]
+ def match(self, request) -> NoReturn:
+ """
+ Tries to match the request against the internally stored pattern. Returns any matched
+ function arguments.
+
+ :param request: Request to attempt matching.
+ :return: List of matched argument values (possibly empty) or None if not matching.
+ """
+ raise NotImplementedError("The match-method must be implemented.")
+
+
+
+
+
+[docs]
+class regex(PatternMatcher):
+ """
+ Implementation of :class:`PatternMatcher` that compiles the specified pattern into a regular
+ expression.
+ """
+
+ def __init__(self, pattern) -> None:
+ super(regex, self).__init__(pattern)
+
+ self.compiled_pattern = re.compile(pattern.encode())
+
+ @property
+ def arg_count(self):
+ return self.compiled_pattern.groups
+
+ @property
+ def argument_mappings(self) -> None:
+ return None
+
+
+[docs]
+ def match(self, request):
+ match = self.compiled_pattern.match(request)
+
+ if match is None:
+ return None
+
+ return match.groups()
+
+
+
+
+
+[docs]
+class scanf(regex):
+ """
+ Interprets the specified pattern as a scanf format. Internally, the scanf_ package is used
+ to transform the format into a regular expression. Please consult the documentation of scanf_
+ for valid pattern specifications.
+
+ By default, the resulting regular expression matches exactly. Consider this example:
+
+ .. sourcecode:: Python
+
+ exact = scanf("T=%f")
+ not_exact = scanf("T=%f", exact_match=False)
+
+ The first pattern only matches the string ``T=4.0``, whereas the second would also match
+ ``T=4.0garbage``. Please note that the specifiers like ``%f`` are automatically turned into
+ groups in the generated regular expression.
+
+ :param pattern: Scanf format specification.
+ :param exact_match: Match only if the entire string matches.
+
+ .. _scanf: https://github.com/joshburnett/scanf
+ """
+
+ def __init__(self, pattern, exact_match=True) -> None:
+ self._scanf_pattern = pattern
+
+ generated_regex, self._argument_mappings = scanf_compile(pattern)
+ regex_pattern = generated_regex.pattern
+
+ if exact_match:
+ regex_pattern = "^{}$".format(regex_pattern)
+
+ super(scanf, self).__init__(regex_pattern)
+
+ @property
+ def pattern(self):
+ return self._scanf_pattern
+
+ @property
+ def argument_mappings(self):
+ return self._argument_mappings
+
+
+
+
+[docs]
+class Func:
+ """
+ Objects of this type connect a callable object to a pattern matcher (:class:`PatternMatcher`),
+ which currently comprises :class:`regex` and :class:`scanf`. Strings are also
+ accepted, they are treated like a regular expression internally. This preserves default
+ behavior from older versions of Lewis.
+
+ In general, Func-objects should not be created directly, instead they are created by one of
+ the sub-classes of :class:`CommandBase` using :meth:`~CommandBase.bind`.
+
+ Function arguments are indicated by groups in the regular expression. The number of
+ groups has to match the number of arguments of the function. In earlier versions of Lewis it
+ was possible to pass flags to ``re.compile``, this has been removed for consistency issues
+ in :class:`Var`. It is however still possible to use the exact same flags as part of the
+ regular expression. In the documentation of re_, this is outlined, simply add a group to the
+ expression that contains the flags, for example ``(?i)`` to make the expression case
+ insensitive. This special group does not count towards the matching groups used for argument
+ capture.
+
+ The optional argument_mappings can be an iterable of callables with one parameter of the
+ same length as the number of arguments of the function. The first parameter will be
+ transformed using the first function, the second using the second function and so on.
+ This can be useful to automatically transform strings provided by the adapter into a proper
+ data type such as ``int`` or ``float`` before they are passed to the function. In case the
+ pattern is of type :class:`scanf`, this is optional (but will override the mappings
+ provided by the matcher).
+
+ The return_mapping argument is similar, it should map the return value of the function
+ to a string. The default map function only does that when the supplied value
+ is not None. It can also be set to a numeric value or a string constant so that the
+ command always returns the same value. If it is ``None``, the return value is not
+ modified at all.
+
+ Finally, documentation can be provided by passing the doc-argument. If it is omitted,
+ the docstring of the bound function is used and if that is not present, left empty.
+
+ :param func: Function to be called when pattern matches or member of device/interface.
+ :param pattern: :class:`regex`, :class:`scanf` object or string.
+ :param argument_mappings: Iterable with mapping functions from string to some type.
+ :param return_mapping: Mapping function for return value of method.
+ :param doc: Description of the command. If not supplied, the docstring is used.
+
+ :raises: RuntimeError: If the function cannot be mapped for any reason.
+
+ .. _re: https://docs.python.org/2/library/re.html#regular-expression-syntax
+ """
+
+ def __init__(
+ self, func, pattern, argument_mappings=None, return_mapping=None, doc=None
+ ) -> None:
+ if not callable(func):
+ raise RuntimeError("Can not construct a Func-object from a non callable object.")
+
+ self.func = func
+
+ func_name = getattr(func, "__name__", repr(func))
+
+ if isinstance(pattern, str):
+ try:
+ pattern = regex(pattern)
+ except re.error as e:
+ raise RuntimeError(
+ f"The pattern '{pattern}' for function '{func_name}' is invalid regex: {e}"
+ )
+
+ self.matcher = pattern
+
+ if argument_mappings is None:
+ argument_mappings = self.matcher.argument_mappings or None
+
+ try:
+ inspect.getcallargs(func, *[None] * self.matcher.arg_count)
+ except TypeError:
+ raise RuntimeError(
+ "The number of arguments for function '{}' matched by pattern "
+ "'{}' is not compatible with number of defined "
+ "groups in pattern ({}).".format(
+ func_name,
+ self.matcher.pattern,
+ self.matcher.arg_count,
+ )
+ )
+
+ if argument_mappings is not None and (self.matcher.arg_count != len(argument_mappings)):
+ raise RuntimeError(
+ "Supplied argument mappings for function matched by pattern '{}' specify {} "
+ "argument(s), but the function has {} arguments.".format(
+ self.matcher, len(argument_mappings), self.matcher.arg_count
+ )
+ )
+
+ self.argument_mappings = argument_mappings
+ self.return_mapping = return_mapping
+ self.doc = doc or (inspect.getdoc(self.func) if callable(self.func) else None)
+
+ def can_process(self, request):
+ return self.matcher.match(request) is not None
+
+ def process_request(self, request):
+ match = self.matcher.match(request)
+
+ if match is None:
+ raise RuntimeError("Request can not be processed.")
+
+ args = self.map_arguments(match)
+
+ return self.map_return_value(self.func(*args))
+
+
+[docs]
+ def map_arguments(self, arguments):
+ """
+ Returns the mapped function arguments. If no mapping functions are defined, the arguments
+ are returned as they were supplied.
+
+ :param arguments: List of arguments for bound function as strings.
+ :return: Mapped arguments.
+ """
+ if self.argument_mappings is None:
+ return arguments
+
+ return [f(a) for f, a in zip(self.argument_mappings, arguments)]
+
+
+
+[docs]
+ def map_return_value(self, return_value):
+ """
+ Returns the mapped return_value of a processed request. If no return_mapping has been
+ defined, the value is returned as is. If return_mapping is a static value, that value
+ is returned, ignoring return_value completely.
+
+ :param return_value: Value to map.
+ :return: Mapped return value.
+ """
+ if callable(self.return_mapping):
+ return self.return_mapping(return_value)
+
+ if self.return_mapping is not None:
+ return self.return_mapping
+
+ return return_value
+
+
+
+
+
+[docs]
+class CommandBase:
+ """
+ This is the common base class of :class:`Cmd` and :class:`Var`. The concept of commands for
+ the stream adapter is based on connecting a callable object to a pattern that matches an
+ inbound request.
+
+ The type of pattern can be either an implementation of :class:`PatternMatcher`
+ (regex or scanf format specification) or a plain string (which is treated as a regular
+ expression).
+
+ For free function and lambda expressions this is straightforward: the function object can
+ simply be stored together with the pattern. Most often however, the callable
+ is a method of the device or interface object - these do not exist when the commands are
+ defined.
+
+ This problem is solved by introducing a "bind"-step in :class:`StreamAdapter`. So instead
+ of a function object, both :class:`Cmd` and :class:`Var` store the name of a member of device
+ or interface. At "bind-time", this is translated into the correct callable.
+
+ So instead of using :class:`Cmd` or :class:`Var` directly, both classes' :meth:`bind`-methods
+ return an iterable of :class:`Func`-objects which can be used for processing requests.
+ :class:`StreamAdapter` performs this bind-step when it's constructed. For details regarding
+ the implementations, please see the corresponding classes.
+
+ .. seealso::
+
+ Please take a look at :class:`Cmd` for exposing callable objects or methods of
+ device/interface and :class:`Var` for exposing attributes and properties.
+
+ To see how argument_mappings, return_mapping and doc are applied, please look at
+ :class:`Func`.
+
+ :param func: Function to be called when pattern matches or member of device/interface.
+ :param pattern: Pattern to match (:class:`PatternMatcher` or string).
+ :param argument_mappings: Iterable with mapping functions from string to some type.
+ :param return_mapping: Mapping function for return value of method.
+ :param doc: Description of the command. If not supplied, the docstring is used.
+ """
+
+ def __init__(
+ self, func, pattern, argument_mappings=None, return_mapping=None, doc=None
+ ) -> None:
+ super(CommandBase, self).__init__()
+
+ self.func = func
+ self.pattern = pattern
+ self.argument_mappings = argument_mappings
+ self.return_mapping = return_mapping
+ self.doc = doc
+
+ def bind(self, target) -> NoReturn:
+ raise NotImplementedError("Binders need to implement the bind method.")
+
+
+
+
+[docs]
+class Cmd(CommandBase):
+ """
+ This class is an implementation of :class:`CommandBase` that can expose a callable object
+ or a named method of the device/interface controlled by :class:`StreamAdapter`.
+
+ .. sourcecode:: Python
+
+ def random():
+ return 6
+
+ SomeInterface(StreamInterface):
+ commands = {
+ Cmd(lambda: 4, pattern='^R$', doc='Returns a random number.'),
+ Cmd('random', pattern='^RR$', doc='Better random number.'),
+ Cmd(random, pattern='^RRR$', doc='The best random number.'),
+ }
+
+ def random(self):
+ return 5
+
+ The interface defined by the above example has three commands, ``R`` which calls a lambda
+ function that always returns 4, ``RR``, which calls ``SomeInterface.random`` and returns 5 and
+ lastly ``RRR`` which calls the free function defined above and returns the best random number.
+
+ For a detailed explanation of requirements to the constructor arguments, please refer to the
+ documentation of :class:`Func`, to which the arguments are forwarded.
+
+ .. seealso ::
+
+ :class:`Var` exposes attributes and properties of a device object. The documentation
+ of :class:`Func` provides more information about the common constructor arguments.
+
+ :param func: Function to be called when pattern matches or member of device/interface.
+ :param pattern: Pattern to match (:class:`PatternMatcher` or string).
+ :param argument_mappings: Iterable with mapping functions from string to some type.
+ :param return_mapping: Mapping function for return value of method.
+ :param doc: Description of the command. If not supplied, the docstring is used.
+ """
+
+ def __init__(
+ self,
+ func,
+ pattern,
+ argument_mappings=None,
+ return_mapping=lambda x: None if x is None else str(x),
+ doc=None,
+ ) -> None:
+ super(Cmd, self).__init__(func, pattern, argument_mappings, return_mapping, doc)
+
+ def bind(self, target):
+ method = self.func if callable(self.func) else getattr(target, self.func, None)
+
+ if method is None:
+ return None
+
+ return [
+ Func(
+ method,
+ self.pattern,
+ self.argument_mappings,
+ self.return_mapping,
+ self.doc,
+ )
+ ]
+
+
+
+
+[docs]
+class Var(CommandBase):
+ r"""
+ With this implementation of :class:`CommandBase` it's possible to expose plain data attributes
+ or properties of device or interface. Getting and setting a value are separate procedures
+ which both have their own pattern, read_pattern and write_pattern to match a command each.
+ Please note that write_pattern has to have exactly one group defined to match a parameter.
+
+ Due to this separation, parameters can be made read-only, write-only or read-write in the
+ interface:
+
+ .. sourcecode:: Python
+
+ class SomeInterface(StreamInterface):
+ commands = {
+ Var('foo', read_pattern='^F$', write_pattern=r'^F=(\d+)$',
+ argument_mappings=(int,), doc='An integer attribute.'),
+ Var('bar' read_pattern='^B$')
+ }
+
+ foo = 10
+
+ @property
+ def bar(self):
+ return self.foo + 5
+
+ @bar.setter
+ def bar(self, new_bar):
+ self.foo = new_bar - 5
+
+ In the above example, the foo attribute can be read and written, it's automatically converted
+ to an integer, while bar is a property that can only be read via the stream protocol.
+
+ .. seealso::
+
+ For exposing methods and free functions, there's the :class:`Cmd`-class.
+
+ :param target_member: Attribute or property of device/interface to expose.
+ :param read_pattern: Pattern to match for getter (:class:`PatternMatcher` or string).
+ :param write_pattern: Pattern to match for setter (:class:`PatternMatcher` or string).
+ :param argument_mappings: Iterable with mapping functions from string to some type,
+ only applied to setter.
+ :param return_mapping: Mapping function for return value of method,
+ applied to getter and setter.
+ :param doc: Description of the command. If not supplied, the docstring is used. For plain data
+ attributes the only way to get docs is to supply this argument.
+ """
+
+ def __init__(
+ self,
+ target_member,
+ read_pattern=None,
+ write_pattern=None,
+ argument_mappings=None,
+ return_mapping=lambda x: None if x is None else str(x),
+ doc=None,
+ ) -> None:
+ super(Var, self).__init__(target_member, None, argument_mappings, return_mapping, doc)
+
+ self.target = None
+
+ self.read_pattern = read_pattern
+ self.write_pattern = write_pattern
+
+ def bind(self, target):
+ if self.func not in dir(target):
+ return None
+
+ funcs = []
+
+ if self.read_pattern is not None:
+
+ def getter():
+ return getattr(target, self.func)
+
+ # Copy docstring if target is a @property
+ prop = getattr(type(target), self.func, None)
+ if prop and inspect.isdatadescriptor(prop):
+ getter.__doc__ = "Getter: " + inspect.getdoc(prop)
+
+ funcs.append(
+ Func(
+ getter,
+ self.read_pattern,
+ return_mapping=self.return_mapping,
+ doc=self.doc,
+ )
+ )
+
+ if self.write_pattern is not None:
+
+ def setter(new_value) -> None:
+ setattr(target, self.func, new_value)
+
+ # Copy docstring if target is a @property
+ prop = getattr(type(target), self.func, None)
+ if prop and inspect.isdatadescriptor(prop):
+ setter.__doc__ = "Setter: " + inspect.getdoc(prop)
+
+ funcs.append(
+ Func(
+ setter,
+ self.write_pattern,
+ argument_mappings=self.argument_mappings,
+ return_mapping=self.return_mapping,
+ doc=self.doc,
+ )
+ )
+
+ return funcs
+
+
+
+
+[docs]
+class StreamAdapter(Adapter):
+ """
+ The StreamAdapter is the bridge between the Device Interface and the TCP Stream networking
+ backend implementation.
+
+ Available adapter options are:
+
+ - bind_address: IP of network adapter to bind on (defaults to 0.0.0.0, or all adapters)
+ - port: Port to listen on (defaults to 9999)
+ - telnet_mode: When True, overrides in- and out-terminator for CRNL (defaults to False)
+
+ :param options: Dictionary with options.
+ """
+
+ default_options = {"telnet_mode": False, "bind_address": "0.0.0.0", "port": 9999}
+
+ def __init__(self, options=None) -> None:
+ super(StreamAdapter, self).__init__(options)
+ self._server = None
+
+ @property
+ def documentation(self):
+ commands = [
+ "{}:\n{}".format(
+ cmd.matcher.pattern,
+ format_doc_text(cmd.doc or inspect.getdoc(cmd.func) or ""),
+ )
+ for cmd in sorted(self.interface.bound_commands, key=lambda x: x.matcher.pattern)
+ ]
+
+ options = format_doc_text(
+ "Listening on: {}\nPort: {}\nRequest terminator: {}\nReply terminator: {}".format(
+ self._options.bind_address,
+ self._options.port,
+ repr(self.interface.in_terminator),
+ repr(self.interface.out_terminator),
+ )
+ )
+
+ return "\n\n".join(
+ [
+ inspect.getdoc(self.interface) or "",
+ "Parameters\n==========",
+ options,
+ "Commands\n========",
+ ]
+ + commands
+ )
+
+
+[docs]
+ def start_server(self) -> None:
+ """
+ Starts the TCP stream server, binding to the configured host and port.
+ Host and port are configured via the command line arguments.
+
+ .. note:: The server does not process requests unless
+ :meth:`handle` is called in regular intervals.
+
+ """
+ if self._server is None:
+ if self._options.telnet_mode:
+ self.interface.in_terminator = "\r\n"
+ self.interface.out_terminator = "\r\n"
+
+ self._server = StreamServer(
+ self._options.bind_address,
+ self._options.port,
+ self.interface,
+ self.device_lock,
+ )
+
+
+
+[docs]
+ def stop_server(self) -> None:
+ if self._server is not None:
+ self._server.close()
+ self._server = None
+
+
+ @property
+ def is_running(self):
+ return self._server is not None
+
+
+[docs]
+ def handle(self, cycle_delay=0.1) -> None:
+ """
+ Spend approximately ``cycle_delay`` seconds to process requests to the server.
+
+ :param cycle_delay: S
+ """
+ asyncore.loop(cycle_delay, count=1)
+ self._server.process(int(cycle_delay * 1000))
+
+
+
+
+
+[docs]
+class StreamInterface(InterfaceBase):
+ r"""
+ This class is used to provide a TCP-stream based interface to a device.
+
+ Many hardware devices use a protocol that is based on exchanging text with a client via
+ a TCP stream. Sometimes RS232-based devices are also exposed this way via an adapter-box.
+ This adapter makes it easy to mimic such a protocol.
+
+ This class has the following attributes which may be overridden by subclasses:
+
+ - protocol: What this interface is called for purposes of the -p commandline option.
+ Defaults to "stream".
+ - in_terminator, out_terminator: These define how lines are terminated when transferred
+ to and from the device respectively. They are stripped/added automatically.
+ Inverse of protocol file InTerminator and OutTerminator. The default is ``\\r``.
+ - readtimeout: How many msec to wait for additional data between packets, once transmission
+ of an incoming command has begun. Inverse of ReadTimeout in protocol files.
+ Defaults to 100 (ms). Set to 0 to disable timeout completely.
+ - commands: A list of :class:`~CommandBase`-objects that define mappings between protocol
+ and device/interface methods/attributes.
+
+ By default, commands are expressed as regular expressions, a simple example may look like this:
+
+ .. sourcecode:: Python
+
+ class SimpleDeviceStreamInterface(StreamInterface):
+ commands = [
+ Cmd('set_speed', r'^S=([0-9]+)$', argument_mappings=[int]),
+ Cmd('get_speed', r'^S\?$')
+ Var('speed', read_pattern=r'^V\?$', write_pattern=r'^V=([0-9]+)$')
+ ]
+
+ def set_speed(self, new_speed):
+ self.device.speed = new_speed
+
+ def get_speed(self):
+ return self.device.speed
+
+ The interface has two commands, ``S?`` to return the speed and ``S=10`` to set the speed
+ to an integer value. It also exposes the same speed attribute as a variable, using auto-
+ generated ``V?`` and ``V=10`` commands.
+
+ As in the :class:`lewis.adapters.epics.EpicsInterface`, it does not matter whether the
+ wrapped method is a part of the device or of the interface, this is handled automatically when
+ a new device is assigned to the ``device``-property.
+
+ In addition, the :meth:`handle_error`-method can be overridden. It is called when an exception
+ is raised while handling commands.
+ """
+
+ protocol = "stream"
+
+ in_terminator = "\r"
+ out_terminator = "\r"
+
+ readtimeout = 100
+
+ commands = None
+
+ def __init__(self) -> None:
+ super(StreamInterface, self).__init__()
+ self.bound_commands = None
+
+ @property
+ def adapter(self):
+ return StreamAdapter
+
+ def _bind_device(self) -> None:
+ """
+ This method implements ``_bind_device`` from :class:`~lewis.core.devices.InterfaceBase`.
+ It binds Cmd and Var definitions to implementations in Interface and Device.
+ """
+ patterns = set()
+
+ self.bound_commands = []
+
+ for cmd in self.commands:
+ bound = cmd.bind(self) or cmd.bind(self.device) or None
+
+ if bound is None:
+ raise RuntimeError(
+ "Unable to produce callable object for non-existing member '{}' "
+ "of device or interface.".format(cmd.func)
+ )
+
+ for bound_cmd in bound:
+ pattern = bound_cmd.matcher.pattern
+ if pattern in patterns:
+ raise RuntimeError(
+ "The regular expression {} is " "associated with multiple commands.".format(
+ pattern
+ )
+ )
+
+ patterns.add(pattern)
+
+ self.bound_commands.append(bound_cmd)
+
+
+[docs]
+ def handle_error(self, request, error) -> None:
+ """
+ Override this method to handle exceptions that are raised during command processing.
+ The default implementation does nothing, so that any errors are silently ignored.
+
+ :param request: The request that resulted in the error.
+ :param error: The exception that was raised.
+ """
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module contains :class:`Adapter`, which serves as a base class for concrete adapter
+implementations in :mod:`lewis.adapters`. It also contains :class:`AdapterCollection` which can
+be used to store multiple adapters and manage them together.
+"""
+
+import inspect
+import logging
+import threading
+from collections import namedtuple
+from types import TracebackType
+from typing import Any, Optional, Type
+
+from lewis.core.devices import DeviceBase, InterfaceBase
+from lewis.core.exceptions import LewisException
+from lewis.core.logging import has_log
+from lewis.core.utils import dict_strict_update
+
+
+
+[docs]
+class NoLock:
+ """
+ A dummy context manager that raises a RuntimeError when it's used. This makes it easier to
+ detect cases where an :class:`Adapter` has not received the proper lock-object to make sure
+ that device/interface access is synchronous.
+ """
+
+ def __enter__(self) -> None:
+ raise RuntimeError(
+ "The attempted action requires a proper threading.Lock-object, "
+ "but none was available."
+ )
+
+ def __exit__(
+ self,
+ exctype: Optional[Type[BaseException]],
+ excinst: Optional[BaseException],
+ exctb: Optional[TracebackType],
+ ) -> None:
+ pass
+
+
+
+
+[docs]
+@has_log
+class Adapter:
+ """
+ Base class for adapters
+
+ This class serves as a base class for concrete adapter implementations that expose a device via
+ a certain communication protocol. It defines the minimal interface that an adapter must provide
+ in order to fit seamlessly into other parts of the framework
+ (most importantly :class:`~lewis.core.simulation.Simulation`).
+
+ Sub-classes should re-define the ``protocol``-member to something appropriate. While it is
+ explicitly supported to modify it in concrete device interface implementations, it is good
+ to have a default (for example ``epics`` or ``stream``).
+
+ An adapter should provide everything that is needed for the communication via the protocol it
+ defines. This might involve constructing a server-object, configuring it and starting the
+ service (this should happen in :meth:`start_server`). Due to the large differences between
+ protocols it is very hard to provide general guidelines here. Please take a look at the
+ implementations of existing adapters (:class:`~lewis.adapters.epics.EpicsAdapter`,
+ :class:`~lewis.adapters.stream.StreamAdapter`),to get some examples.
+
+ In principle, an adapter can exist on its own, but it only really becomes useful when a device
+ is bound to it. To do this, assign an object derived from
+ :class:`lewis.core.devices.DeviceBase` to the ``device``-property. Sub-classes have to
+ implement :meth:`_bind_device` to achieve actual binding behavior.
+
+ It is possible to pass a dictionary with configuration options to Adapter. The keys of
+ the dictionary are accessible as properties of the ``_options``-member. Only keys that are
+ in the ``default_options`` member of the class are accepted. Inheriting classes must override
+ ``default_options`` to be a dictionary with the possible options for the adapter.
+
+ Each adapter has a ``lock`` member, which contains a :class:`NoLock` by default. To make
+ device access thread-safe, any adapter should acquire this lock before interacting with
+ the device (or interface). This means that before starting the server component of an Adapter,
+ a proper Lock-object needs to be assigned to ``lock``.
+
+ :param options: Configuration options for the adapter.
+ """
+
+ default_options = {}
+
+ def __init__(self, options: dict[str, Any] | None = None) -> None:
+ super(Adapter, self).__init__()
+ self._interface = None
+
+ self.device_lock: threading.Lock | NoLock = NoLock()
+
+ options = options or {}
+ combined_options = dict(self.default_options)
+
+ try:
+ dict_strict_update(combined_options, options)
+ except RuntimeError as e:
+ raise LewisException(
+ "Invalid options found: {}. Valid options are: {}".format(
+ ", ".join(e.args[1]), ", ".join(self.default_options.keys())
+ )
+ )
+
+ options_type = namedtuple("adapter_options", list(combined_options.keys()))
+ self._options = options_type(**combined_options)
+
+ @property
+ def protocol(self) -> str | None:
+ if self.interface is None:
+ return None
+
+ return self.interface.protocol
+
+ @property
+ def interface(self) -> InterfaceBase | None:
+ """
+ The device property contains the device-object exposed by the adapter.
+
+ The property can be set from the outside, at that point the adapter will
+ call :meth:`_bind_device` (which is implemented in each adapter sub-class)
+ and thus re-bind its commands etc. to call the new device.
+ """
+ return self._interface
+
+ @interface.setter
+ def interface(self, new_interface: InterfaceBase | None) -> None:
+ self._interface = new_interface
+
+ @property
+ def documentation(self) -> str:
+ """
+ This property can be overridden in a sub-class to provide protocol documentation to users
+ at runtime. By default it returns the indentation cleaned-up docstring of the class.
+ """
+ return inspect.getdoc(self) or ""
+
+
+[docs]
+ def start_server(self) -> None:
+ """
+ This method must be re-implemented to start the infrastructure required for the
+ protocol in question. These startup operations are not supposed to be carried out on
+ construction of the adapter in order to preserve control over when services are
+ started during a run of a simulation.
+
+ .. note::
+
+ This method may be called multiple times over the lifetime of the Adapter, so it is
+ important to make sure that this does not cause problems.
+
+ .. seealso:: See :meth:`stop_server` for shutting down the adapter.
+ """
+ raise NotImplementedError(
+ "Adapters must implement start_server to construct and setup any servers or mechanism "
+ "required for network communication."
+ )
+
+
+
+[docs]
+ def stop_server(self) -> None:
+ """
+ This method must be re-implemented to stop and tear down anything that has been setup
+ in :meth:`start_server`. This method should close all connections to clients that have
+ been established since the adapter has been started.
+
+ .. note::
+
+ This method may be called multiple times over the lifetime of the Adapter, so it is
+ important to make sure that this does not cause problems.
+ """
+ raise NotImplementedError(
+ "Adapters must implement stop_server to tear down anything that has been setup in "
+ "start_server."
+ )
+
+
+ @property
+ def is_running(self) -> bool:
+ """
+ This property indicates whether the Adapter's server is running and listening. The result
+ of calls to :meth:`start_server` and :meth:`stop_server` should be reflected as expected.
+ """
+ raise NotImplementedError(
+ "Adapters must implement the is_running property to indicate whether "
+ "a server is currently running and listening for requests."
+ )
+
+
+[docs]
+ def handle(self, cycle_delay: float = 0.1) -> None:
+ """
+ This function is called on each cycle of a simulation. It should process requests that are
+ made via the protocol that exposes the device. The time spent processing should be
+ approximately ``cycle_delay`` seconds, during which the adapter may block the current
+ process. It is desirable to stick to the provided time, but deviations are permissible if
+ necessary due to the way the protocol works.
+
+ :param cycle_delay: Approximate time spent processing requests.
+ """
+ pass
+
+
+
+
+
+[docs]
+@has_log
+class AdapterCollection:
+ """
+ A container to manage the adapters of a device
+
+ This container is designed to keep all adapters that expose a device in one place and interact
+ with them in a uniform way.
+
+ Adapters can be passed as arguments upon construction or added later on using
+ :meth:`add_adapter` (and removed using :meth:`remove_adapter`). The available protocols can be
+ queried using the :meth:`protocols` property.
+
+ Each adapter can be started and stopped separately by supplying protocol names to
+ :meth:`connect` and :meth:`disconnect`, both methods accept an arbitrary number of arguments,
+ so that any subset of the stored protocols can be handled at any time. Supplying no protocol
+ names at all will start/stop all adapters. These semantics also apply for :meth:`is_connected`
+ and `documentation`.
+
+ This class also makes sure that all adapters use the same Lock for device interaction.
+
+ :param args: List of adapters to add to the container
+ """
+
+ def __init__(self, *args: Adapter) -> None:
+ self._adapters = {}
+
+ self._threads = {}
+ self._running = {}
+ self._lock = threading.Lock()
+ self.log: logging.Logger
+
+ for adapter in args:
+ self.add_adapter(adapter)
+
+ @property
+ def device_lock(self) -> threading.Lock:
+ """
+ This lock is passed to each adapter when it's started. It's supposed to be used to ensure
+ that the device is only accessed from one thread at a time, for example during network IO.
+ :class:`~lewis.core.simulation.Simulation` uses this lock to block the device during the
+ simulation cycle calculations.
+ """
+ return self._lock
+
+
+[docs]
+ def set_device(self, new_device: DeviceBase) -> None:
+ """Bind the new device to all interfaces managed by the adapters in the collection."""
+ for adapter in self._adapters.values():
+ adapter.interface.device = new_device
+
+
+
+[docs]
+ def add_adapter(self, adapter: Adapter) -> None:
+ """
+ Adds the supplied adapter to the container but raises a ``RuntimeError`` if there's
+ already an adapter registered for the same protocol.
+
+ :param adapter: Adapter to add to the container
+ """
+ if adapter.protocol in self._adapters:
+ raise RuntimeError(
+ "Adapter for protocol '{}' is already registered.".format(adapter.protocol)
+ )
+
+ self._adapters[adapter.protocol] = adapter
+
+
+
+[docs]
+ def remove_adapter(self, protocol: str) -> None:
+ """
+ Tries to remove the adapter for the specified protocol, raises a ``RuntimeError`` if there
+ is no adapter registered for that particular protocol.
+
+ :param protocol: Protocol to remove from container
+ """
+ if protocol not in self._adapters:
+ raise RuntimeError(
+ "Can not remove adapter for protocol '{}', none registered.".format(protocol)
+ )
+
+ del self._adapters[protocol]
+
+
+ @property
+ def protocols(self) -> list[str]:
+ """List of protocols for which adapters are registered."""
+ return list(self._adapters.keys())
+
+
+[docs]
+ def connect(self, *args: str) -> None:
+ """
+ This method starts an adapter for each specified protocol in a separate thread, if the
+ adapter is not already running.
+
+ :param args: List of protocols for which to start adapters or empty for all.
+ """
+ for adapter in self._get_adapters(list(args)):
+ self._start_server(adapter)
+
+
+ def _start_server(self, adapter: Adapter) -> None:
+ if adapter.protocol not in self._threads:
+ self.log.info("Connecting device interface for protocol '%s'", adapter.protocol)
+
+ adapter_thread = threading.Thread(target=self._adapter_loop, args=(adapter, 0.01))
+ adapter_thread.daemon = True
+
+ self._threads[adapter.protocol] = adapter_thread
+ self._running[adapter.protocol] = threading.Event()
+
+ adapter_thread.start()
+
+ # Block until server is actually listening
+ self._running[adapter.protocol].wait(2.0)
+ if not self._running[adapter.protocol].is_set():
+ raise LewisException("Adapter for '%s' failed to start!" % adapter.protocol)
+
+ def _adapter_loop(self, adapter: Adapter, dt: float) -> None:
+ adapter.device_lock = self._lock # This ensures that the adapter is using the correct lock
+ adapter.start_server()
+
+ self._running[adapter.protocol].set()
+
+ self.log.debug("Starting adapter loop for protocol %s.", adapter.protocol)
+ while self._running[adapter.protocol].is_set():
+ adapter.handle(dt)
+
+ adapter.stop_server()
+
+
+[docs]
+ def disconnect(self, *args: str) -> None:
+ """
+ Stops all adapters for the specified protocols. The method waits for each adapter thread
+ to join, so it might hang if the thread is not terminating correctly.
+
+ :param args: List of protocols for which to stop adapters or empty for all.
+ """
+ for adapter in self._get_adapters(list(args)):
+ self._stop_server(adapter)
+
+
+ def _stop_server(self, adapter: Adapter) -> None:
+ if adapter.protocol in self._threads:
+ self.log.info("Disconnecting device interface for protocol '%s'", adapter.protocol)
+
+ self._running[adapter.protocol].clear()
+ self._threads[adapter.protocol].join()
+
+ del self._threads[adapter.protocol]
+ del self._running[adapter.protocol]
+
+
+[docs]
+ def is_connected(self, *args: str) -> bool | dict[str | None, bool]:
+ """
+ If only one protocol is supplied, a single bool is returned with the connection status.
+ Otherwise, this method returns a dictionary of adapter connection statuses for the supplied
+ protocols. If no protocols are supplied, all adapter statuses are returned.
+
+ :param args: List of protocols for which to start adapters or empty for all.
+ :return: Boolean for single adapter or dict of statuses for multiple.
+ """
+ status_dict = {
+ adapter.protocol: adapter.is_running for adapter in self._get_adapters(list(args))
+ }
+
+ if len(args) == 1:
+ status = list(status_dict.values())[0]
+ if status is None:
+ return False
+ return status
+
+ return status_dict
+
+
+
+[docs]
+ def configuration(self, *args: str) -> dict[str | None, dict[str, Any]]:
+ """
+ Returns a dictionary that contains the options for the specified adapter. The dictionary
+ keys are the adapter protocols.
+
+ :param args: List of protocols for which to list options, empty for all adapters.
+ :return: Dict of protocol: option-dict pairs.
+ """
+ return {
+ adapter.protocol: adapter._options._asdict()
+ for adapter in self._get_adapters(list(args))
+ }
+
+
+
+[docs]
+ def documentation(self, *args: str) -> str:
+ """
+ Returns the concatenated documentation for the adapters specified by the supplied
+ protocols or all of them if no arguments are provided.
+
+ :param args: List of protocols for which to get documentation or empty for all.
+ :return: Documentation for all selected adapters.
+ """
+ return "\n\n".join(adapter.documentation for adapter in self._get_adapters(list(args)))
+
+
+ def _get_adapters(self, protocols: list[str]) -> list[Adapter]:
+ """
+ Internal method to map protocols back to adapters. If the list of protocols contains an
+ invalid entry (e.g. a protocol for which there is no adapter), a ``RuntimeError``
+ is raised.
+
+ :param protocols: List of protocols, can be empty to return all adapters.
+ :return: Adapters according to the rules described above.
+ """
+ invalid_protocols = set(protocols) - set(self.protocols)
+
+ if invalid_protocols:
+ raise RuntimeError(
+ "No adapter registered for protocols: {}".format(", ".join(invalid_protocols))
+ )
+
+ return [self._adapters[proto] for proto in protocols or self.protocols]
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+Defines functions that model typical behavior, such as a value approaching a target linearly at
+a certain rate.
+"""
+
+
+
+[docs]
+def linear(current: float, target: float, rate: float, dt: float):
+ """
+ This function returns the new value after moving towards
+ target at the given speed constantly for the time dt.
+
+ If for example the current position is 10 and the target is -20,
+ the returned value will be less than 10 if rate and dt are greater
+ than 0:
+
+ .. sourcecode:: Python
+
+ new_pos = linear(10, -20, 10, 0.1) # new_pos = 9
+
+ The function makes sure that the returned value never overshoots:
+
+ .. sourcecode:: Python
+
+ new_pos = linear(10, -20, 10, 100) # new_pos = -20
+
+ :param current: The current value of the variable to be changed.
+ :param target: The target value to approach.
+ :param rate: The rate at which the parameter should move towards target.
+ :param dt: The time for which to calculate the change.
+ :return: The new variable value.
+ """
+ sign = (target > current) - (target < current)
+
+ if not sign:
+ return current
+
+ new_value = current + sign * rate * dt
+
+ if sign * new_value > sign * target:
+ return target
+
+ return new_value
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module provides client code for objects exposed via JSON-RPC over ZMQ.
+
+.. seealso::
+
+ The server-part for these client classes is defined
+ in the module :mod:`~lewis.core.control_server`.
+"""
+
+import types
+import uuid
+
+import zmq
+
+# This does not import .exceptions, because absolute_import from the __future__ module
+try:
+ import exceptions
+except ImportError:
+ import builtins as exceptions
+
+
+
+[docs]
+class RemoteException(Exception):
+ """
+ This exception type replaces exceptions that are raised on the server,
+ but unknown (i.e. not in the exceptions-module) on the client side.
+ To retain as much information as possible, the exception type on the server and
+ the message are stored.
+
+ :param exception_type: Type of the exception on the server side.
+ :param message: Exception message on the server side.
+ """
+
+ def __init__(self, exception_type, message) -> None:
+ super(RemoteException, self).__init__(
+ "Exception on server side of type '{}': '{}'".format(exception_type, message)
+ )
+
+ self.server_side_type = exception_type
+ self.server_side_message = message
+
+
+
+
+[docs]
+class ProtocolException(Exception):
+ """
+ An exception type for exceptions related to the transport protocol, i.e.
+ malformed requests etc.
+ """
+
+
+
+
+[docs]
+class ControlClient:
+ """
+ This class provides an interface to a ControlServer instance on
+ the server side. Proxies to exposed objects can be obtained either
+ directly via get_object or, in case the server exposes a collection
+ of objects at the top level, a dictionary of named objects can be
+ obtained via get_object_collection.
+
+ If a timeout is supplied, all underlying network operations time out
+ after the specified time (in milliseconds), for no timeout specify ``None``.
+
+ :param host: Host the control server is running on.
+ :param port: Port on which the control server is listening.
+ :param timeout: Timeout in milliseconds for ZMQ operations.
+ """
+
+ def __init__(self, host="127.0.0.1", port="10000", timeout=3000) -> None:
+ self.timeout = timeout if timeout is not None else -1
+
+ self._socket = self._get_zmq_req_socket()
+
+ self._connection_string = "tcp://{0}:{1}".format(host, port)
+ self._socket.connect(self._connection_string)
+
+ def _get_zmq_req_socket(self):
+ context = zmq.Context()
+ context.setsockopt(zmq.REQ_CORRELATE, 1)
+ context.setsockopt(zmq.REQ_RELAXED, 1)
+ context.setsockopt(zmq.SNDTIMEO, self.timeout)
+ context.setsockopt(zmq.RCVTIMEO, self.timeout)
+ context.setsockopt(zmq.LINGER, 0)
+ return context.socket(zmq.REQ)
+
+
+[docs]
+ def json_rpc(self, method, *args):
+ """
+ This method takes a ZMQ REQ-socket and submits a JSON-object containing
+ the RPC (JSON-RPC 2.0 format) to the supplied method with the supplied arguments.
+ Then it waits for a reply from the server and blocks until it has received
+ a JSON-response. The method returns the response and the id it used to tag
+ the original request, which is a random UUID (uuid.uuid4).
+
+ :param method: Method to call on remote.
+ :param args: Arguments to method call.
+ :return: JSON result and request id.
+ """
+ request_id = str(uuid.uuid4())
+
+ try:
+ self._socket.send_json(
+ {"method": method, "params": args, "jsonrpc": "2.0", "id": request_id}
+ )
+
+ return self._socket.recv_json(), request_id
+ except zmq.error.Again:
+ raise ProtocolException(
+ "The ZMQ connection to {} timed out after {:.2f}s.".format(
+ self._connection_string, self.timeout / 1000
+ )
+ )
+
+
+ def get_object(self, object_name=""):
+ api, request_id = self.json_rpc(object_name + ":api")
+
+ if "result" not in api or api["id"] != request_id:
+ raise ProtocolException("Failed to retrieve API of remote object.")
+
+ object_type = type(str(api["result"]["class"]), (ObjectProxy,), {})
+ methods = api["result"]["methods"]
+
+ glue = "." if object_name else ""
+ return object_type(self, methods, object_name + glue)
+
+
+[docs]
+ def get_object_collection(self, object_name=""):
+ """
+ If the remote end exposes a collection of objects under the supplied object name (empty
+ for top level), this method returns a dictionary of these objects stored under their
+ names on the server.
+
+ This function performs n + 1 calls to the server, where n is the number of objects.
+
+ :param object_name: Object name on the server. This is required if the object collection
+ is not the top level object.
+ """
+
+ object_names = self.get_object(object_name).get_objects()
+
+ return {obj: self.get_object(obj) for obj in object_names}
+
+
+
+
+
+[docs]
+class ObjectProxy:
+ """
+ This class serves as a base class for dynamically created classes on the
+ client side that represent server-side objects. Upon initialization,
+ this class takes the supplied methods and installs appropriate proxy methods
+ or properties into the object and class respectively. Because of that
+ class manipulation, this class must never be used directly.
+ Instead, it should be used as a base-class for dynamically created types
+ that mirror types on the server, like this:
+
+ .. sourcecode:: Python
+
+ proxy = type("SomeClassName", (ObjectProxy,), {})(connection, methods, prefix)
+
+ There is however, the class ControlClient, which automates all that
+ and provides objects that are ready to use.
+
+ Exceptions on the server are propagated to the client. If the exception is not part
+ of the exceptions-module (builtins for Python 3), a RemoteException is raised instead
+ which contains information about the server side exception.
+
+ All RPC method names are prefixed with the supplied prefix, which is usually the
+ object name on the server plus a dot.
+
+ :param connection: ControlClient-object for remote calls.
+ :param members: List of strings to generate methods and properties.
+ :param prefix: Usually object name on the server plus dot.
+ """
+
+ def __init__(self, connection, members, prefix="") -> None:
+ self._properties = set()
+
+ self._connection = connection
+ self._prefix = prefix
+ self._add_member_proxies(members)
+
+ def _make_request(self, method, *args):
+ """
+ This method performs a JSON-RPC request via the object's ZMQ socket. If successful,
+ the result is returned, otherwise exceptions are raised. Server side exceptions are
+ raised using the same type as on the server if they are part of the exceptions-module.
+ Otherwise, a RemoteException is raised.
+
+ :param method: Method of the object to call on the remote.
+ :param args: Positional arguments to the method call.
+ :return: Result of the remote call if successful.
+ """
+ response, request_id = self._connection.json_rpc(self._prefix + method, *args)
+
+ if "id" not in response:
+ raise ProtocolException("JSON-RPC response does not contain ID field.")
+
+ if response["id"] != request_id:
+ raise ProtocolException(
+ "ID of JSON-RPC request ({}) did not match response ({}).".format(
+ request_id, response["id"]
+ )
+ )
+
+ if "result" in response:
+ return response["result"]
+
+ if "error" in response:
+ if "data" in response["error"]:
+ exception_type = response["error"]["data"]["type"]
+ exception_message = response["error"]["data"]["message"]
+
+ if not hasattr(exceptions, exception_type):
+ raise RemoteException(exception_type, exception_message)
+ else:
+ exception = getattr(exceptions, exception_type)
+ raise exception(exception_message)
+ else:
+ raise ProtocolException(response["error"]["message"])
+
+ def _add_member_proxies(self, members) -> None:
+ for member in [str(m) for m in members]:
+ if ":set" in member or ":get" in member:
+ self._properties.add(member.split(":")[-2].split(".")[-1])
+ else:
+ setattr(self, member, self._create_method_proxy(member))
+
+ for prop in self._properties:
+ setattr(
+ type(self),
+ prop,
+ property(self._create_getter_proxy(prop), self._create_setter_proxy(prop)),
+ )
+
+ def _create_getter_proxy(self, property_name):
+ def getter(obj):
+ return obj._make_request(property_name + ":get")
+
+ return getter
+
+ def _create_setter_proxy(self, property_name):
+ def setter(obj, value):
+ return obj._make_request(property_name + ":set", value)
+
+ return setter
+
+ def _create_method_proxy(self, method_name):
+ def method_wrapper(obj, *args):
+ return obj._make_request(method_name, *args)
+
+ return types.MethodType(method_wrapper, self)
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module contains classes to expose objects via a JSON-RPC over ZMQ server. Lewis uses
+this infrastructure in :class:`~lewis.core.simulation.Simulation`.
+
+.. seealso::
+
+ Client classes for the service defined in this module can be found in
+ :mod:`~lewis.core.control_client`.
+
+"""
+
+import inspect
+import json
+import socket
+
+import zmq
+from jsonrpc import JSONRPCResponseManager
+
+from lewis.core.exceptions import LewisException
+from lewis.core.logging import has_log
+
+
+
+[docs]
+class ExposedObject:
+ """
+ ExposedObject is a class that makes it easy to expose an object via the
+ JSONRPCResponseManager from the json-rpc package, where it can serve as a dispatcher.
+ For this purpose it exposes a read-only dict-like interface.
+
+ The basic problem solved by this wrapper is that plain data members of an object are not
+ really captured well by the RPC-approach, where a client performs function calls on a
+ remote machine and gets the result back.
+
+ The supplied object is inspected using dir(object) and all entries that do not start
+ with a _ are exposed in a way that depends on whether the corresponding member
+ is a method or a property (either in the Python-sense or the general OO-sense). Methods
+ are stored directly, and stored in an internal dict where the method name is the key and
+ the callable method object is the value. For properties, a getter- and a setter function
+ are generated, which are then stored in the same dict. The names of these methods for
+ a property called ``a`` are ``a:get`` and ``a:set``. The separator has been chosen to be
+ colon because it can't be part of a valid Python identifier.
+
+ If the second argument is not empty, it is interpreted to be the list of members
+ to expose and only those are actually exposed. This can be used to explicitly expose
+ members of an object that start with an underscore. If all but one or two members
+ should be exposed, it's also possible to use the exclude-argument to explicitly
+ exclude a few members. Both parameters can be used in combination, the exclude-list
+ takes precedence.
+
+ In certain situations it is desirable to acquire a lock before accessing the exposed object,
+ for example when multiple threads are accessing it on the server side. For this purpose,
+ the ``lock``-parameter can be used. If it is not ``None``, the exposed methods are wrapped
+ in a function that acquires the lock before accessing ``obj``, and releases it afterwards.
+
+ :param obj: The object to expose.
+ :param members: This list of methods will be exposed. (defaults to all public members)
+ :param exclude: Members in this list will not be exposed.
+ :param exclude_inherited: Should inherited members be excluded? (defaults to False)
+ :param lock: ``threading.Lock`` that is used when accessing ``obj``.
+ """
+
+ def __init__(self, obj, members=None, exclude=None, exclude_inherited=False, lock=None) -> None:
+ super(ExposedObject, self).__init__()
+
+ self._object = obj
+ self._function_map = {}
+ self._lock = lock
+
+ self._add_function(":api", self.get_api)
+
+ exposed_members = members if members else self._public_members()
+ exclude = list(exclude or [])
+ if exclude_inherited:
+ for base in inspect.getmro(type(obj))[1:]:
+ exclude += dir(base)
+
+ for method in exposed_members:
+ if method not in exclude:
+ self._add_member_wrappers(method)
+
+ def _public_members(self):
+ """
+ Returns a list of members that do not start with an underscore.
+ """
+ return [prop for prop in dir(self._object) if not prop.startswith("_")]
+
+ def _add_member_wrappers(self, member) -> None:
+ """
+ This method probes the supplied member of the wrapped object and inserts an appropriate
+ entry into the internal method map. Getters and setters for properties get a suffix
+ ':get' and ':set' respectively.
+
+ :param member: The member of the wrapped object to expose
+ """
+ method_object = getattr(type(self._object), member, None) or getattr(self._object, member)
+
+ if callable(method_object):
+ self._add_function(member, getattr(self._object, member))
+ else:
+ self._add_property(member)
+
+
+[docs]
+ def get_api(self):
+ """
+ This method returns the class name and a list of exposed methods.
+ It is exposed to RPC-clients by an instance of ExposedObjectCollection.
+
+ :return: A dictionary describing the exposed API (consisting of a class name and methods).
+ """
+ return {
+ "class": type(self._object).__name__,
+ "methods": list(self._function_map.keys()),
+ }
+
+
+ def __getitem__(self, item):
+ return self._function_map[item]
+
+ def __len__(self) -> int:
+ return len(self._function_map)
+
+ def __iter__(self):
+ return iter(self._function_map)
+
+ def __contains__(self, item) -> bool:
+ return item in self._function_map
+
+ def _add_property(self, name) -> None:
+ self._add_function("{}:get".format(name), lambda: getattr(self._object, name))
+ self._add_function("{}:set".format(name), lambda value: setattr(self._object, name, value))
+
+ def _add_function(self, name, function) -> None:
+ if not callable(function):
+ raise TypeError("Only callable objects can be exposed.")
+
+ if self._lock is not None:
+
+ def create_locking_wrapper(f):
+ def locking_wrapper_function(*args, **kwargs):
+ with self._lock:
+ return f(*args, **kwargs)
+
+ return locking_wrapper_function
+
+ function = create_locking_wrapper(function)
+
+ self._function_map[name] = function
+
+ def _remove_function(self, name) -> None:
+ del self._function_map[name]
+
+
+
+
+[docs]
+class ExposedObjectCollection(ExposedObject):
+ """
+ This class helps expose a number of objects (plain or RPCObject) by
+ exposing the methods of each object as
+
+ .. sourcecode:: Python
+
+ name.method
+
+ Furthermore it exposes each object's API as a method with the following name:
+
+ .. sourcecode:: Python
+
+ name: api
+
+ A list of exposed objects can be obtained by calling the following method from the client:
+
+ .. sourcecode:: Python
+
+ :objects
+
+ :param named_objects: Dictionary of of name: object pairs.
+ """
+
+ def __init__(self, named_objects) -> None:
+ super(ExposedObjectCollection, self).__init__(self, ("get_objects",))
+ self._object_map = {}
+
+ if named_objects:
+ for name, obj in named_objects.items():
+ self.add_object(obj, name)
+
+ self._add_function("get_objects", self.get_objects)
+
+
+[docs]
+ def add_object(self, obj, name) -> None:
+ """
+ Adds the supplied object to the collection under the supplied name. If the name is already
+ in use, a RuntimeError is raised. If the object is not an instance of
+ :class:`ExposedObject`, the method automatically constructs one.
+
+ :param obj: Object to add to the collection.
+ :param name: Name of the exposed object.
+ """
+ if name in self._object_map:
+ raise RuntimeError("An object is already registered under that name.")
+
+ exposed_object = self._object_map[name] = (
+ obj if isinstance(obj, ExposedObject) else ExposedObject(obj)
+ )
+
+ for method_name in exposed_object:
+ glue = "." if not method_name.startswith(":") else ""
+ self._add_function(name + glue + method_name, exposed_object[method_name])
+
+
+
+[docs]
+ def remove_object(self, name) -> None:
+ """
+ Remove the object exposed under that name. If no object is registered under the supplied
+ name, a RuntimeError is raised.
+
+ :param name: Name of object to be removed.
+ """
+ if name not in self._object_map:
+ raise RuntimeError("No object with name {} is registered.".format(name))
+
+ for fn_name in list(self._function_map.keys()):
+ if fn_name.startswith(name + ".") or fn_name.startswith(name + ":"):
+ self._remove_function(fn_name)
+
+ del self._object_map[name]
+
+
+
+[docs]
+ def get_objects(self):
+ """Returns the names of the exposed objects."""
+ return list(self._object_map.keys())
+
+
+
+
+
+[docs]
+@has_log
+class ControlServer:
+ """
+ This server opens a ZMQ REP-socket at the given host and port when start_server
+ is called.
+
+ The server constructs an :class:`ExposedObjectCollection` from the supplied
+ name: object-dictionary and uses that as a handler for JSON-RPC requests. If it is an
+ instance of :class:`ExposedObject`, that is used directly.
+
+ Each time process is called, the server tries to get request data and responds to that.
+ If there is no data, the method does nothing.
+
+ Please note that this RPC-service comes without any security, authentication, etc.
+ Only use it to expose objects on a trusted network and be aware that anyone on that
+ network can access the exposed objects without any restrictions.
+
+ :param object_map: Dictionary with name: object-pairs to construct an
+ ExposedObjectCollection or ExposedObject
+ :param connection_string: String with host:port pair for binding control server.
+ """
+
+ def __init__(self, object_map, connection_string) -> None:
+ super(ControlServer, self).__init__()
+
+ try:
+ host, port = connection_string.split(":")
+ except ValueError:
+ raise LewisException(
+ "'{}' is not a valid control server initialization string. "
+ 'A string of the form "host:port" is expected.'.format(connection_string)
+ )
+
+ try:
+ self.host = socket.gethostbyname(host)
+ except socket.gaierror:
+ raise LewisException("Could not resolve control server host: {}".format(host))
+
+ self.port = port
+
+ if isinstance(object_map, ExposedObject):
+ self._exposed_object = object_map
+ else:
+ self._exposed_object = ExposedObjectCollection(object_map)
+
+ self._socket = None
+
+ @property
+ def is_running(self):
+ """
+ This property is ``True`` if the server is running.
+ """
+ return self._socket is not None
+
+ @property
+ def exposed_object(self):
+ """
+ The exposed object. This is a read only property.
+ """
+ return self._exposed_object
+
+
+[docs]
+ def start_server(self) -> None:
+ """
+ Binds the server to the configured host and port and starts listening.
+ """
+ if self._socket is None:
+ context = zmq.Context()
+ self._socket = context.socket(zmq.REP)
+ self._socket.setsockopt(zmq.RCVTIMEO, 100)
+ self._socket.bind("tcp://{0}:{1}".format(self.host, self.port))
+
+ self.log.info("Listening on %s:%s", self.host, self.port)
+
+
+ def _unhandled_exception_response(self, request_id, exception):
+ return {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {
+ "message": "Server error",
+ "code": -32000,
+ "data": {
+ "message": exception.args,
+ "args": [exception.args],
+ "type": type(exception).__name__,
+ },
+ },
+ }
+
+
+[docs]
+ def process(self, blocking=False) -> None:
+ """
+ Each time this method is called, the socket tries to retrieve data and passes
+ it to the JSONRPCResponseManager, which in turn passes the RPC to the
+ ExposedObjectCollection.
+
+ In case no data are available, the method does nothing. This behavior is required for
+ Lewis where everything is running in one thread. The central loop can call process
+ at some point to process remote calls, so the RPC-server does not introduce its own
+ infinite processing loop.
+
+ If the server has not been started yet (via :meth:`start_server`), a RuntimeError
+ is raised.
+
+ :param blocking: If True, this function will block until it has received data or a timeout
+ is triggered. Default is False to preserve behavior of prior versions.
+ """
+ if self._socket is None:
+ raise RuntimeError("The server has not been started yet, use start_server to do so.")
+
+ try:
+ request = self._socket.recv_unicode(flags=zmq.NOBLOCK if not blocking else 0)
+
+ self.log.debug("Got request %s", request)
+
+ try:
+ response = JSONRPCResponseManager.handle(request, self._exposed_object)
+ self._socket.send_unicode(response.json)
+
+ self.log.debug("Sent response %s", response.json)
+ except TypeError as e:
+ self._socket.send_json(
+ self._unhandled_exception_response(json.loads(request)["id"], e)
+ )
+ except zmq.Again:
+ pass
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module contains :class:`DeviceBase` as a base class for other device classes and
+infrastructure that can import devices from a module (:class:`DeviceRegistry`). The latter also
+produces factory-like objects that create device instances and interfaces based on setups
+(:class:`DeviceBuilder`).
+"""
+
+import importlib
+from typing import NoReturn
+
+from lewis.core.exceptions import LewisException
+from lewis.core.logging import has_log
+from lewis.core.utils import get_members, get_submodules
+
+
+
+[docs]
+@has_log
+class DeviceBase:
+ """
+ This class is a common base for :class:`~lewis.devices.Device` and
+ :class:`~lewis.devices.StateMachineDevice`. It is mainly used in the device
+ discovery process.
+ """
+
+
+
+
+[docs]
+@has_log
+class InterfaceBase:
+ """
+ This class is a common base for protocol specific interfaces that are exposed by a subclass of
+ :class:`~lewis.core.adapters.Adapter`. This base class is not meant to be used directly in
+ a device package - this is what the interfaces in :mod:`lewis.adapters` are for.
+
+ There is a 1:1 correspondence between device and interface, where the interface holds a
+ reference to the device. It can be changed through the ``device``-property.
+ """
+
+ protocol = None
+
+ def __init__(self) -> None:
+ super(InterfaceBase, self).__init__()
+ self._device = None
+
+ @property
+ def adapter(self) -> NoReturn:
+ """
+ Adapter type that is required to process and expose interfaces of this type. Must be
+ implemented in subclasses.
+ """
+ raise NotImplementedError(
+ "An interface type must specify which adapter it is compatible "
+ "with. Please implement the adapter-property."
+ )
+
+ @property
+ def device(self):
+ """
+ The device this interface is bound to. When a new device is set, :meth:`_bind_device` is
+ called, where the interface can react to the device change if necessary.
+ """
+ return self._device
+
+ @device.setter
+ def device(self, new_device) -> None:
+ self._device = new_device
+ self._bind_device()
+
+ def _bind_device(self) -> None:
+ """
+ This method should perform any binding steps between device and interface. The result
+ of this binding step is generally used by the adapter to process network traffic.
+
+ The default implementation does nothing.
+ """
+ pass
+
+
+
+
+[docs]
+def is_device(obj):
+ """
+ Returns True if obj is a device type (derived from DeviceBase), but not defined in
+ :mod:`lewis.core.devices` or :mod:`lewis.devices`.
+
+ :param obj: Object to test.
+ :return: True if obj is a device type.
+ """
+ return (
+ isinstance(obj, type)
+ and issubclass(obj, DeviceBase)
+ and obj.__module__ not in ("lewis.devices", "lewis.core.devices")
+ )
+
+
+
+
+[docs]
+def is_interface(obj):
+ """
+ Returns True if obj is an interface (derived from :class:`InterfaceBase`), but not defined in
+ :mod:`lewis.adapters`, where concrete interfaces for protocols are defined.
+
+ :param obj: Object to test.
+ :return: True if obj is an interface type.
+ """
+ return (
+ isinstance(obj, type)
+ and issubclass(obj, InterfaceBase)
+ and not (
+ obj.__module__.startswith("lewis.core.devices")
+ or obj.__module__.startswith("lewis.adapters")
+ )
+ )
+
+
+
+
+[docs]
+@has_log
+class DeviceBuilder:
+ """
+ This class takes a module object (for example imported via importlib.import_module or via the
+ :class:`DeviceRegistry`) and inspects it so that it's possible to construct devices and
+ interfaces.
+
+ In order for the class to work properly, the device module has to adhere to a few rules.
+ Device types, which means classes inheriting from :class:`DeviceBase`, are imported directly
+ from the device module, equivalent to the following:
+
+ .. sourcecode :: Python
+
+ from device_name import SimulatedDeviceType
+
+ If ``SimulatedDeviceType`` is defined in the ``__init__.py``, there's nothing else to do. If
+ the device class is defined elsewhere, it must be imported in the ``__init__.py`` file as
+ written above. If there is only one device type (which is probably the most common case), it is
+ assumed to be default device type.
+
+ Setups are discovered in two locations, the first one is a dict called ``setups`` in the device
+ module, which must contain setup names as keys and as values again a dict. This inner dict has
+ one mandatory key called ``device_type`` and one optional key ``parameters`` containing the
+ constructor arguments for the specified device type:
+
+ .. sourcecode:: Python
+
+ setups = dict(
+ broken=dict(
+ device_type=SimulatedDeviceType,
+ parameters=dict(
+ override_initial_state="error",
+ override_initial_data=dict(target=-10, position=-20.0),
+ ),
+ )
+ )
+
+ The other location is a sub-package called `setups`, which should in turn contain modules. Each
+ module must contain a variable ``device_type`` and a variable ``parameters`` which are
+ analogous to the keys in the dict described above. This allows for more complex setups which
+ define additional classes and so on.
+
+ The ``default`` setup is special, it is used when no setup is supplied to
+ :meth:`create_device`. If the setup ``default`` is not defined, one is created with the default
+ device type. This has two consequences, no setups need to be defined for very simple devices,
+ but if multiple device types are defined, a ``default`` setup must be defined.
+
+ A setup can be supplied to the :meth:`create_device`.
+
+ Lastly, the builder tries to discover device interfaces, which are currently classes based on
+ :class:`lewis.adapters.InterfaceBase`. These are looked for in the module and in a sub-package
+ called ``interfaces`` (which should contain modules with adapters like the ``setups`` package).
+
+ Each interface has a protocol, if a protocol occurs more than once in a device module,
+ a RuntimeError is raised.
+ """
+
+ def __init__(self, module) -> None:
+ self._module = module
+
+ submodules = get_submodules(self._module)
+
+ self._device_types = self._discover_devices(submodules.get("devices"))
+ self._setups = self._discover_setups(submodules.get("setups"))
+ self._interfaces = self._discover_interfaces(submodules.get("interfaces"))
+
+ self.log.debug(
+ "Discovered the following items in '%s': Devices: %s; Setups: %s; Interfaces: %s",
+ self._module.__name__,
+ ", ".join(device_t.__name__ for device_t in self._device_types),
+ ", ".join(self._setups.keys()),
+ ", ".join("(%s: %s)" % (k, v.__name__) for k, v in self._interfaces.items()),
+ )
+
+ def _discover_devices(self, devices_package):
+ devices = list(get_members(self._module, is_device).values())
+
+ if devices_package is None:
+ return devices
+
+ for module in get_submodules(devices_package).values():
+ devices += list(get_members(module, is_device).values())
+
+ return devices
+
+ def _discover_setups(self, setups_package):
+ setups = getattr(self._module, "setups", {})
+
+ all_setups = setups if isinstance(setups, dict) else {}
+
+ if setups_package is not None:
+ for name, setup_module in get_submodules(setups_package).items():
+ existing_setup = all_setups.get(name)
+
+ if existing_setup is not None:
+ raise RuntimeError(
+ "The setup '{}' is defined twice in device '{}'.".format(
+ existing_setup, self.name
+ )
+ )
+
+ all_setups[name] = {
+ "device_type": getattr(setup_module, "device_type", self.default_device_type),
+ "parameters": getattr(setup_module, "parameters", {}),
+ }
+
+ if "default" not in all_setups:
+ all_setups["default"] = {"device_type": self.default_device_type}
+
+ return all_setups
+
+ def _discover_interfaces(self, interface_package):
+ all_interfaces = []
+
+ if interface_package is not None:
+ for interface_module in get_submodules(interface_package).values():
+ all_interfaces += list(get_members(interface_module, is_interface).values())
+
+ all_interfaces += list(get_members(self._module, is_interface).values())
+
+ interfaces = {}
+ for interface in all_interfaces:
+ existing_interface = interfaces.get(interface.protocol)
+
+ if existing_interface is not None:
+ raise RuntimeError(
+ "The protocol '{}' is defined in two interfaces for device '{}':\n"
+ " {} (in {})\n"
+ " {} (in {})\n"
+ "One of the protocol names needs to be changed.".format(
+ interface.protocol,
+ self.name,
+ existing_interface.__name__,
+ existing_interface.__module__,
+ interface.__name__,
+ interface.__module__,
+ )
+ )
+
+ interfaces[interface.protocol] = interface
+
+ return interfaces
+
+ @property
+ def framework_version(self):
+ return getattr(self._module, "framework_version", None)
+
+ @property
+ def name(self):
+ """
+ The name of the device, which is also the name of the device module.
+ """
+ return self._module.__name__.split(".")[-1]
+
+ @property
+ def device_types(self):
+ """
+ This property contains a dict of all device types in the device module. The keys are
+ type names, the values are the types themselves.
+ """
+ return self._device_types
+
+ @property
+ def default_device_type(self):
+ """
+ If the module only defines one device type, it is the default device type. It is used
+ whenever a setup does not provide a ``device_type``.
+ """
+ if len(self.device_types) == 1:
+ return self.device_types[0]
+
+ return None
+
+ @property
+ def interfaces(self):
+ """
+ This property contains a map with protocols as keys and interface types as values.
+ The types are imported from the ``interfaces`` sub-module and from the device module
+ itself. If two interfaces with the same protocol are discovered, a RuntimeError is raiesed.
+ """
+ return self._interfaces
+
+ @property
+ def protocols(self):
+ """All available protocols for this device."""
+ return list(self.interfaces.keys())
+
+ @property
+ def default_protocol(self):
+ """In case only one protocol exists for the device, this is the default protocol."""
+ if len(self.protocols) == 1:
+ return self.protocols[0]
+
+ return None
+
+ @property
+ def setups(self):
+ """
+ A map with all available setups. Setups are imported from the ``setups`` dictionary
+ in a device module and from the ``setups`` sub-module. If no ``default``-setup exists,
+ one is created using the default_device_type. If there are several device types in
+ the module, the default setup must be provided explicitly.
+ """
+ return self._setups
+
+ def _create_device_instance(self, device_type, **kwargs):
+ if device_type not in self.device_types:
+ raise RuntimeError("Can not create instance of non-device type.")
+
+ return device_type(**kwargs)
+
+
+[docs]
+ def create_device(self, setup=None):
+ """
+ Creates a device object according to the provided setup. If no setup is provided,
+ the default setup is used. If the setup can't be found, a LewisException is raised.
+ This can also happen if the device type specified in the setup is invalid.
+
+ :param setup: Name of the setup from which to create device.
+ :return: Device object initialized according to the provided setup.
+ """
+ setup_name = setup if setup is not None else "default"
+
+ if setup_name not in self.setups:
+ raise LewisException(
+ "Failed to find setup '{}' for device '{}'. "
+ "Available setups are:\n {}".format(
+ setup, self.name, "\n ".join(self.setups.keys())
+ )
+ )
+
+ setup_data = self.setups[setup_name]
+ device_type = setup_data.get("device_type") or self.default_device_type
+
+ self.log.debug(
+ "Trying to create device '%s' (setup: %s, device type: %s)",
+ self.name,
+ setup_name,
+ device_type.__name__ if device_type else "",
+ )
+
+ try:
+ return self._create_device_instance(device_type, **setup_data.get("parameters", {}))
+ except RuntimeError:
+ raise LewisException(
+ "The setup '{}' you tried to load does not specify a valid device type, but the "
+ "device module '{}' provides multiple device types so that no meaningful "
+ "default can be deduced.".format(setup_name, self.name)
+ )
+
+
+ def get_interface_type(self, protocol=None):
+ return self.interfaces[protocol]
+
+
+[docs]
+ def create_interface(self, protocol=None, *args, **kwargs):
+ """
+ Returns an interface that implements the provided protocol. If the protocol is not
+ known, a LewisException is raised. All additional arguments are forwarded
+ to the interface constructor (see :class:`~lewis.adapters.Adapter` for details).
+
+ :param protocol: Protocol which the interface must implement.
+ :param args: Positional arguments that are passed on to the interface.
+ :param kwargs: Keyword arguments that are passed on to the interface.
+ :return: Instance of the interface type.
+ """
+ protocol = protocol if protocol is not None else self.default_protocol
+
+ self.log.debug("Trying to create interface for protocol '%s'", protocol)
+
+ try:
+ return self.interfaces[protocol](*args, **kwargs)
+ except KeyError:
+ raise LewisException(
+ "'{}' is not a valid protocol for device '{}', select one via the -p option.\n"
+ "Available protocols are: \n {}".format(
+ protocol, self.name, "\n ".join(self.interfaces.keys())
+ )
+ )
+
+
+
+
+
+[docs]
+@has_log
+class DeviceRegistry:
+ """
+ This class takes the name of a module and constructs a :class:`DeviceBuilder` from
+ each sub-module. The available devices can be queried and a DeviceBuilder can be
+ obtained for each device:
+
+ .. sourcecode:: Python
+
+ from lewis.core.devices import DeviceRegistry
+
+ registry = DeviceRegistry("lewis.devices")
+ chopper_builder = registry.device_builder("chopper")
+
+ # construct device, interface, ...
+
+ If the module can not be imported, a LewisException is raised.
+
+ :param device_module: Name of device module from which devices are loaded.
+ """
+
+ def __init__(self, device_module) -> None:
+ try:
+ self._device_module = importlib.import_module(device_module)
+ except ImportError:
+ raise LewisException(
+ "Failed to import module '{}' for device discovery. "
+ "Make sure that it is in the PYTHONPATH.\n"
+ "See also the -a option of lewis.".format(device_module)
+ )
+
+ self._devices = {
+ name: DeviceBuilder(module)
+ for name, module in get_submodules(self._device_module).items()
+ }
+
+ self.log.debug(
+ "Devices loaded from '%s': %s",
+ device_module,
+ ", ".join(self._devices.keys()),
+ )
+
+ @property
+ def devices(self):
+ """All available device names."""
+ return self._devices.keys()
+
+
+[docs]
+ def device_builder(self, name):
+ """
+ Returns a :class:`DeviceBuilder` instance that can be used to create device objects
+ based on setups, as well as device interfaces. If the device name is not stored
+ in the internal map, a LewisException is raised.
+
+ Each DeviceBuilder has a ``framework_version``-member, which specifies the version
+ of Lewis the device has been written for. If the version does not match the current
+ framework version, it is only possible to obtain those device builders calling the
+ method with ``strict_versions`` set to ``False``, otherwise a
+ :class:`~lewis.core.exceptions.LewisException` is raised. A warning message is logged
+ in all cases. If ``framework_version`` is ``None`` (e.g. not specified at all), it
+ is accepted unless ``strict_versions`` is set to ``True``.
+
+ :param name: Name of the device.
+ :return: :class:`DeviceBuilder`-object for requested device.
+ """
+ try:
+ return self._devices[name]
+ except KeyError:
+ raise LewisException(
+ "No device with the name '{}' could be found. "
+ "Possible names are:\n {}\n"
+ "See also the -k option to add inspect a different module.".format(
+ name, "\n ".join(self.devices)
+ )
+ )
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+Defines exception types specific to lewis. The main intention of these exception types is
+that they can be caught and meaningful messages can be displayed to the user.
+"""
+
+
+
+[docs]
+class LewisException(Exception):
+ """
+ This exception type is used to distinguish exceptions that are expected
+ from unexpected ones. This enables better error handling and more importantly
+ better presentation of errors to the users.
+ """
+
+
+
+
+[docs]
+class LimitViolationException(Exception):
+ """
+ An exception that can be raised in a device to indicate a limit violation. It is for example
+ raised by the :class:`~lewis.core.utils.check_limits`.
+ """
+
+
+
+
+[docs]
+class AccessViolationException(Exception):
+ """
+ This exception can be raised in situation where the performed action (accessing a property or
+ similar) is not allowed. An example is :class:`~lewis.adapters.epics.BoundPV` for enforcing
+ read-only PVs.
+ """
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module contains everything logging-related in Lewis. There is one relevant
+module level variable that defines the default log format, ``default_log_format``.
+
+All places that use logging in Lewis prefix their logger names with ``lewis`` so
+that you can easily control the logs caused by Lewis if you use it as a library.
+Lewis uses the default settings of the logging module, so if you use Lewis as a
+library and do not have any logging enabled, messages that are more severe than ``WARNING``
+are printed to stdout. For details on how to disable that behavior, change levels
+for certain loggers and so on, please refer to the documentation
+of the standard `logging`_ library.
+
+.. _logging: https://docs.python.org/2/library/logging.html
+"""
+
+import logging
+from typing import Callable, ParamSpec, Protocol, Type, TypeVar, overload
+
+
+
+
+
+
+P = ParamSpec("P")
+T = TypeVar("T")
+
+root_logger_name = "lewis"
+default_log_format = "%(asctime)s %(levelname)s %(name)s: %(message)s"
+
+
+@overload
+def has_log(target: Type[T]) -> Type[T]: ...
+@overload
+def has_log(target: Callable[P, T]) -> Callable[P, T]: ...
+
+
+
+[docs]
+def has_log(target):
+ """
+ This is a decorator to add logging functionality to a class or function.
+
+ Applying this decorator to a class or function will add two new members:
+
+ - ``log`` is an instance of ``logging.Logger``. The name of the logger is
+ set to ``lewis.Foo`` for a class named Foo.
+ - ``_set_logging_context`` is a method that modifies the name of the logger
+ when the class is used in a certain context.
+
+ If ``context`` is a string, that string is directly inserted between ``lewis``
+ and ``Foo``, so that the logger name would be ``lewis.bar.Foo`` if context
+ was ``'bar'``. The more common case is probably ``context`` being an object of
+ some class, in which case the class name is inserted. If ``context`` is an object
+ of type ``Bar``, the logger name of ``Foo`` would be ``lewis.Bar.Foo``.
+
+ To provide a more concrete example in terms of Lewis, this is used for the state
+ machine logger in a device. So the logs of the state machine belonging to a certain
+ device appear in the log as originating from ``lewis.DeviceName.StateMachine``, which
+ makes it possible to distinguish between messages from different state machines.
+
+ Example for how to use logging in a class:
+
+ .. sourcecode:: Python
+
+ from lewis.core.logging import has_log
+
+
+ @has_log
+ class Foo(Base):
+ def __init__(self):
+ super(Foo, self).__init__()
+
+ def bar(self, baz):
+ self.log.debug("Called bar with parameter baz=%s", baz)
+ return baz is not None
+
+ It works similarly for free functions, although the actual logging calls are a bit different:
+
+ .. sourcecode:: Python
+
+ from lewis.core.logging import has_log
+
+
+ @has_log
+ def foo(bar):
+ foo.log.info("Called with argument bar=%s", bar)
+ return bar
+
+ The name of the logger is ``lewis.foo``, the context could also be modified by calling
+ ``foo._set_logging_context``.
+
+ :param target: Target to decorate with logging functionality.
+ """
+ logger_name = target.__name__
+
+ def get_logger_name(context: object = None) -> str:
+ log_names = [root_logger_name, logger_name]
+
+ if context is not None:
+ log_names.insert(1, context if isinstance(context, str) else context.__class__.__name__)
+
+ return ".".join(log_names)
+
+ def _set_logging_context(obj: HasLog, context: object) -> None:
+ """
+ Changes the logger name of this class using the supplied context
+ according to the rules described in the documentation of :func:`has_log`. To
+ clear the context of a class logger, supply ``None`` as the argument.
+
+ :param context: String or object, ``None`` to clear context.
+ """
+ obj.log.name = get_logger_name(context)
+
+ target.log = logging.getLogger(get_logger_name())
+ target._set_logging_context = _set_logging_context
+
+ return target
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module defines two classes related to one of lewis' essential concepts, namely
+the cycle-based approach. :class:`CanProcess` and :class:`CanProcessComposite` implement the
+composite design pattern so that it's possible to form a tree of objects which can perform
+calculations based on an elapsed time Δt.
+"""
+
+
+
+[docs]
+class CanProcess:
+ """
+ The CanProcess class is meant as a base for all things that
+ are able to process on the basis of a time delta (dt).
+
+ The base implementation does nothing.
+
+ There are three methods that can be implemented by sub-classes and are called in the
+ process-method in this order:
+
+ 1. doBeforeProcess
+ 2. doProcess
+ 3. doAfterProcess
+
+ The doBefore- and doAfterProcess methods are only called if a doProcess-method exists.
+ """
+
+ def __init__(self) -> None:
+ super(CanProcess, self).__init__()
+
+ def __call__(self, dt=0):
+ self.process(dt)
+
+ def process(self, dt=0) -> None:
+ if hasattr(self, "doProcess"):
+ if hasattr(self, "doBeforeProcess"):
+ self.doBeforeProcess(dt)
+
+ self.doProcess(dt)
+
+ if hasattr(self, "doAfterProcess"):
+ self.doAfterProcess(dt)
+
+
+
+
+[docs]
+class CanProcessComposite(CanProcess):
+ """
+ This subclass of CanProcess is a convenient way of collecting
+ multiple items that implement the CanProcess interface.
+
+ Items can be added to the composite like this:
+
+ .. sourcecode:: Python
+
+ composite = CanProcessComposite()
+ composite.add_processor(item_that_implements_CanProcess)
+
+ The process-method calls the process-method of each contained
+ item. Specific things that have to be done before or after the
+ containing items are processed can be implemented in the doBefore-
+ and doAfterProcess methods.
+ """
+
+ def __init__(self, iterable=()) -> None:
+ super(CanProcessComposite, self).__init__()
+
+ self._processors = []
+
+ for item in iterable:
+ self.add_processor(item)
+
+ def add_processor(self, other) -> None:
+ if isinstance(other, CanProcess):
+ self._append_processor(other)
+
+ def _append_processor(self, processor) -> None:
+ self._processors.append(processor)
+
+ def doProcess(self, dt) -> None:
+ for processor in self._processors:
+ processor.process(dt)
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+A :class:`~Simulation` combines a :mod:`Device <lewis.devices>` and its interface (derived from
+an :mod:`Adapter <lewis.adapters>`).
+"""
+
+from datetime import datetime
+from threading import Thread
+from time import sleep
+
+from lewis.core.adapters import AdapterCollection
+from lewis.core.control_server import ControlServer, ExposedObject
+from lewis.core.devices import DeviceRegistry
+from lewis.core.logging import has_log
+from lewis.core.utils import seconds_since
+
+
+
+[docs]
+@has_log
+class Simulation:
+ """
+ The Simulation class controls certain aspects of a device simulation,
+ the most important one being time.
+
+ Once :meth:`start` is called, the process-method of the device
+ is called in regular intervals. The time between these calls is
+ influenced by the cycle_delay property. Because of the way some
+ network protocols work, the actual processing time can be
+ longer or shorter, so cycle_delay should be seen as a guideline
+ rather than a guaranteed parameter.
+
+ In the simplest case, the actual time-delta between two cycles
+ is passed to the simulated device so that it can update its internal
+ state according to the elapsed time. It is however possible to set
+ a simulation speed, which serves as a multiplier for this time.
+ If the speed is set to 2 and 0.1 seconds pass between two cycles,
+ the simulation is asked to simulate 0.2 seconds, and so on. Speed 0
+ effectively stops all time dependent calculations in the
+ simulated device.
+
+ Another possibility to pause the simulation is the pause-method. After
+ calling it, all processing in the device is suspended, while the communication
+ adapters continue to work. This can be used to simulate that a device is "hanging".
+ The simulation can be continued using the resume-method.
+
+ A number of status properties provide information about the simulation.
+ The total uptime (in actually elapsed time) can be obtained through the
+ uptime-property, whereas the runtime-property contains the simulated time.
+ The cycles-property indicates the total number of simulation cycles, which
+ does not increase when the simulation is paused.
+
+ Finally, the simulation can be stopped entirely with the stop-method.
+
+ All functionality except for the start-method can be made available to remote
+ computers via a :class:`ControlServer`-instance. The way to expose device and simulation
+ is to pass a 'host:port'-string as the control_server argument,
+ which will construct the control server. Simulation will try to start the
+ control server using the start_server method.
+
+ :param device: The simulated device.
+ :param adapters: Adapters which expose the simulated device.
+ :param device_builder: :class:`~lewis.core.devices.DeviceBuilder` instance to enable setup-
+ switching at runtime.
+ :param control_server: 'host:port'-string to construct control server or None.
+ """
+
+ def __init__(self, device, adapters=(), device_builder=None, control_server=None) -> None:
+ super(Simulation, self).__init__()
+
+ self._device_builder = device_builder
+
+ self._device = device
+ self._adapters = AdapterCollection(*adapters)
+
+ self._speed = 1.0 # Multiplier for delta t
+ self._cycle_delay = 0.1 # Target time between cycles
+
+ self._start_time = None # Real time when the simulation started
+ self._cycles = 0 # Number of cycles processed
+ self._runtime = 0.0 # Total simulation time processed
+
+ self._running = False
+ self._started = False
+ self._stop_commanded = False
+
+ # Constructing the control server must be deferred until the end,
+ # because the construction is not complete at this point
+ self._control_server = None # Just initialize to None and use property setter afterwards
+ self._control_server_thread = None
+ self.control_server = control_server
+
+ self.log.debug(
+ "Created simulation. Device type: %s, Protocol(s): %s, Possible setups for "
+ "switching: %s, Control server: %s",
+ device.__class__.__name__,
+ ", ".join(self._adapters.protocols),
+ ", ".join(device_builder.setups.keys()) if device_builder else None,
+ control_server,
+ )
+
+ def _create_control_server(self, control_server):
+ if control_server is None:
+ return None
+
+ return ControlServer(
+ {
+ "device": ExposedObject(
+ self._device,
+ exclude_inherited=True,
+ lock=self._adapters.device_lock,
+ ),
+ "simulation": ExposedObject(
+ self,
+ exclude=("start", "control_server", "log"),
+ exclude_inherited=True,
+ ),
+ "interface": ExposedObject(
+ self._adapters,
+ exclude=(
+ "device_lock",
+ "add_adapter",
+ "remove_adapter",
+ "handle",
+ "log",
+ ),
+ exclude_inherited=True,
+ ),
+ },
+ control_server,
+ )
+
+ @property
+ def setups(self):
+ """
+ A list of setups that are available. Use :meth:`switch_setup` to
+ change the setup.
+ """
+ return list(self._device_builder.setups.keys()) if self._device_builder is not None else []
+
+
+[docs]
+ def switch_setup(self, new_setup) -> None:
+ """
+ This method switches the setup, which means that it replaces the currently
+ simulated device with a new device, as defined by the setup.
+
+ If any error occurs during setup switching it is logged and re-raised.
+
+ :param new_setup: Name of the new setup to load.
+ """
+ try:
+ self._device = self._device_builder.create_device(new_setup)
+ self._adapters.set_device(self._device)
+ self.log.info("Switched setup to '%s'", new_setup)
+ except Exception as e:
+ self.log.error(
+ "Caught an error while trying to switch setups. Setup not switched, "
+ "simulation continues: %s",
+ e,
+ )
+ raise
+
+
+
+[docs]
+ def start(self) -> None:
+ """
+ Starts the simulation.
+ """
+ self.log.info("Starting simulation")
+
+ self._running = True
+ self._started = True
+ self._stop_commanded = False
+
+ self._start_control_server()
+
+ self._adapters.connect()
+
+ self._start_time = datetime.now()
+
+ delta = 0.0
+
+ while not self._stop_commanded:
+ delta = self._process_cycle(delta)
+
+ self._running = False
+ self._started = False
+
+ self.log.info("Simulation has ended.")
+
+
+ def _start_control_server(self) -> None:
+ if self._control_server is not None and self._control_server_thread is None:
+
+ def control_server_loop() -> None:
+ self._control_server.start_server()
+
+ while not self._stop_commanded:
+ self._control_server.process(blocking=True)
+
+ self.log.info("Stopped processing control server commands, ending thread.")
+
+ self._control_server_thread = Thread(target=control_server_loop)
+ self._control_server_thread.start()
+
+ def _stop_control_server(self) -> None:
+ if self._control_server_thread is not None:
+ self._control_server_thread.join(timeout=1.0)
+ self._control_server_thread = None
+
+ def _process_cycle(self, delta):
+ """
+ Processes one cycle, which consists of one simulation cycle and processing
+ of control server commands. The method measures how long all this takes
+ and returns the elapsed time in seconds.
+
+ :param delta: Elapsed time in last cycle, passed to simulation.
+ :return: Elapsed time in this cycle.
+ """
+ start = datetime.now()
+
+ self._process_simulation_cycle(delta)
+
+ delta = seconds_since(start)
+
+ return delta
+
+ def _process_simulation_cycle(self, delta) -> None:
+ """
+ If the simulation is not paused, the device's process-method is
+ called with the supplied delta, multiplied by the simulation speed.
+
+ If the simulation is paused, the process sleeps for the duration
+ of one cycle_delay.
+
+ :param delta: Time delta passed to simulation.
+ """
+ self.log.debug("Cycle, dt=%s", delta)
+
+ sleep(self._cycle_delay)
+
+ if self._running:
+ delta_simulation = delta * self._speed
+
+ with self._adapters.device_lock:
+ self._device.process(delta_simulation)
+
+ self._cycles += 1
+ self._runtime += delta_simulation
+
+ @property
+ def cycle_delay(self):
+ """
+ Desired time between simulation cycles, this can not be negative.
+ Use 0 for highest possible processing rate.
+ """
+ return self._cycle_delay
+
+ @cycle_delay.setter
+ def cycle_delay(self, delay) -> None:
+ if delay < 0.0:
+ raise ValueError("Cycle delay can not be negative.")
+
+ self._cycle_delay = delay
+
+ self.log.info("Changed cycle delay to %s", self._cycle_delay)
+
+ @property
+ def cycles(self):
+ """
+ Simulation cycles processed since start has been called.
+ """
+ return self._cycles
+
+ @property
+ def uptime(self):
+ """
+ Elapsed time in seconds since the simulation has been started.
+ """
+ if not self._started:
+ return 0.0
+ return seconds_since(self._start_time)
+
+ @property
+ def speed(self):
+ """
+ Simulation speed. Actual elapsed time is multiplied with this property
+ to determine simulated time. Values greater than 1 increase the simulation
+ speed, values between 1 and 0 decrease it. A speed of 0 effectively pauses
+ the simulation.
+ """
+ return self._speed
+
+ @speed.setter
+ def speed(self, new_speed) -> None:
+ if new_speed < 0:
+ raise ValueError("Speed can not be negative.")
+
+ self._speed = new_speed
+
+ self.log.info("Changed speed to %s", self._speed)
+
+ @property
+ def runtime(self):
+ """
+ The accumulated simulation time. Whenever speed is different from 1, this
+ progresses at a different rate than uptime.
+ """
+ return self._runtime
+
+
+[docs]
+ def set_device_parameters(self, parameters) -> None:
+ """
+ Set multiple parameters of the simulated device "simultaneously". The passed
+ parameter is assumed to be device parameter/value dict.
+ The method only allows to set existing attributes. If there are invalid
+ attribute names, the attributes are not updated, instead a RuntimeError
+ is raised. The same happens if any of the parameters are methods, which
+ can not be updated with this mechanisms.
+
+ :param parameters: Dict of device attribute/values to update the device.
+ """
+ invalid_parameters = set(parameters.keys()) - set(
+ x for x in dir(self._device) if not callable(getattr(self._device, x))
+ )
+ if invalid_parameters:
+ raise RuntimeError(
+ "The following parameters do not exist in the device or are methods: {}."
+ "Parameters not updated.".format(invalid_parameters)
+ )
+
+ with self._adapters.device_lock:
+ for name, value in parameters.items():
+ setattr(self._device, name, value)
+
+ self.log.debug("Updated device parameters: %s", parameters)
+
+
+
+[docs]
+ def pause(self) -> None:
+ """
+ Pause the simulation. Can only be called after start has been called.
+ """
+ if not self._running:
+ raise RuntimeError("Can only pause a running simulation.")
+
+ self.log.info("Pausing simulation")
+
+ self._running = False
+
+
+
+[docs]
+ def resume(self) -> None:
+ """
+ Resume a paused simulation. Can only be called after start
+ and pause have been called.
+ """
+ if not self._started or self._running:
+ raise RuntimeError("Can only resume a paused simulation.")
+
+ self.log.info("Resuming simulation")
+
+ self._running = True
+
+
+
+[docs]
+ def stop(self) -> None:
+ """
+ Stops the simulation entirely.
+ """
+ if self.is_started:
+ self.log.warning("Stopping simulation")
+
+ self._stop_commanded = True
+
+ self._stop_control_server()
+ self._adapters.disconnect()
+
+
+ @property
+ def is_started(self):
+ """
+ This property is true if the simulation has been started.
+ """
+ return self._started
+
+ @property
+ def is_paused(self):
+ """
+ True if the simulation is paused (implies that the simulation has been started).
+ """
+ return self._started and not self._running
+
+ @property
+ def control_server(self):
+ """
+ ControlServer-instance that exposes the object to remote machines. Can only
+ be set before start has been called or on a running simulation if no
+ control server was previously present. If the server is not running, it will be started
+ after it has been set.
+ """
+ return self._control_server
+
+ @control_server.setter
+ def control_server(self, control_server) -> None:
+ if self.is_started and self._control_server:
+ raise RuntimeError("Can not replace control server while simulation is running.")
+
+ self._control_server = self._create_control_server(control_server)
+
+ if self.is_started and self._control_server is not None:
+ self._control_server.start_server()
+
+
+
+
+[docs]
+class SimulationFactory:
+ """
+ This class is used to create :class:`Simulation`-objects according to a certain
+ set of parameters, such as device, setup and protocol. To create a simulation, it needs to
+ know where devices are stored:
+
+ .. sourcecode:: Python
+
+ factory = SimulationFactory("lewis.devices")
+
+ The actual creation happens via the :meth:`create`-method:
+
+ .. sourcecode:: Python
+
+ simulation = factory.create("device_name", protocol="protocol")
+
+ The simulation can then be started and stopped as desired.
+
+ .. warning:: This class is meant for internal use at the moment and may change frequently.
+ """
+
+ def __init__(self, devices_package) -> None:
+ self._reg = DeviceRegistry(devices_package)
+
+ @property
+ def devices(self):
+ """Names of available devices."""
+ return self._reg.devices
+
+
+[docs]
+ def get_protocols(self, device):
+ """Returns a list of available protocols for the specified device."""
+ return self._reg.device_builder(device).protocols
+
+
+
+[docs]
+ def create(self, device, setup=None, protocols=None, control_server=None):
+ """
+ Creates a :class:`Simulation` according to the supplied parameters.
+
+ :param device: Name of device.
+ :param setup: Name of the setup for device creation.
+ :param protocols: Dictionary where each key is assigned a dictionary with options for the
+ corresponding :class:`~lewis.core.adapters.Adapter`. For available
+ protocols, see :meth:`get_protocols`.
+ :param control_server: String to construct a control server (host:port).
+ :return: Simulation object according to input parameters.
+ """
+
+ device_builder = self._reg.device_builder(device)
+ device = device_builder.create_device(setup)
+
+ adapters = []
+
+ if protocols is not None:
+ for protocol, options in protocols.items():
+ interface = device_builder.create_interface(protocol)
+ interface.device = device
+
+ adapter = interface.adapter(options=options or {})
+ adapter.interface = interface
+
+ adapters.append(adapter)
+
+ return Simulation(
+ device=device,
+ adapters=adapters,
+ device_builder=device_builder,
+ control_server=control_server,
+ )
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+The statemachine module contains one of lewis' central parts, the cycle-based
+:class:`StateMachine`. The module also contains classes that make it easier to define the
+state machine (:class:`State`, :class:`Transition`). Despite its central nature, it's unlikely
+to be used directly in client code for device simulations - these should be based on
+:class:`StateMachineDevice`, which provides a more convenient interface for that purpose.
+"""
+
+from lewis.core.logging import has_log
+from lewis.core.processor import CanProcess
+
+
+
+[docs]
+class StateMachineException(Exception):
+ """
+ Classes in this module should only raise this type of Exception.
+ """
+
+ pass
+
+
+
+
+[docs]
+class HasContext:
+ """
+ Mixin to provide a Context.
+
+ Creates a `_context` member variable that can be assigned with :meth:`set_context`.
+
+ Any state handler or transition callable that derives from this mixin will
+ receive a context from its :class:`StateMachine` upon initialization (assuming the
+ StateMachine was provided with a context itself).
+ """
+
+ def __init__(self) -> None:
+ super(HasContext, self).__init__()
+ self._context = None
+
+
+[docs]
+ def set_context(self, new_context) -> None:
+ """Assigns the new context to the member variable ``_context``."""
+ self._context = new_context
+
+ if hasattr(self, "_set_logging_context"):
+ self._set_logging_context(self._context)
+
+
+
+
+
+[docs]
+@has_log
+class State(HasContext):
+ """
+ StateMachine state handler base class.
+
+ Provides a way to implement StateMachine event handling behaviour using an
+ object-oriented interface. Once the StateMachine is configured to do so, it
+ will automatically invoke the events in this class when appropriate.
+
+ To use this class, create a derived class and override any events that need
+ custom behaviour. Device context is provided via :class:`HasContext` mixin.
+ """
+
+ def __init__(self) -> None:
+ super(State, self).__init__()
+
+
+[docs]
+ def on_entry(self, dt) -> None:
+ """
+ Handle entry event. Raised once, when this state is entered.
+
+ :param dt: Delta T since last cycle.
+ """
+ pass
+
+
+
+[docs]
+ def in_state(self, dt) -> None:
+ """
+ Handle in-state event.
+
+ Raised repeatedly, once per cycle, while idling in this state. Exactly one
+ in-state event occurs per cycle for every StateMachine. This is always the
+ last event of the cycle.
+
+ :param dt: Delta T since last cycle.
+ """
+ pass
+
+
+
+[docs]
+ def on_exit(self, dt) -> None:
+ """
+ Handle exit event. Raised once, when this state is exited.
+
+ :param dt: Delta T since last cycle.
+ """
+ pass
+
+
+
+
+
+[docs]
+@has_log
+class Transition(HasContext):
+ """
+ StateMachine transition condition base class.
+
+ Provides a way to implement a transition that requires access to the device
+ context. The device context is provided via :class:`HasContext` mixin, and can be
+ accessed as `self._context`.
+
+ To use this class, create a derived class and override the :meth:`__call__` attribute.
+ """
+
+ def __init__(self) -> None:
+ super(Transition, self).__init__()
+
+ def __call__(self):
+ """
+ This is invoked when the StateMachine wants to check whether this transition
+ should occur. This happens on cycles when the StateMachine starts the cycle
+ in the source state of this transition.
+
+ If this call returns True, the StateMachine will transition to the destination
+ state. Any remaining transition checks for the source state are not checked.
+
+ :return: True or False / Should transition occur or not
+ """
+ return True
+
+
+
+
+[docs]
+@has_log
+class StateMachine(CanProcess):
+ """
+ Cycle based state machine.
+
+ :param cfg: dict which contains state machine configuration.
+ :param context: object which is assigned to State and Transition objects as their _context.
+
+ The configuration dict may contain the following keys:
+
+ - initial: Name of the initial state of this machine
+ - states: [optional] Dict of custom state handlers
+ - transitions: [optional] Dict of transitions in this state machine.
+
+ State handlers may be given as a dict, list or State class:
+
+ - dict: May contain keys 'on_entry', 'in_state' and 'on_exit'.
+ - list: May contain up to 3 entries, above events in that order.
+ - class: Should be an instance of a class that derives from State.
+
+ In case of handlers being provided as a dict or a list, values should be callable
+ and may take a single parameter: the Delta T since the last cycle.
+
+ Transitions should be provided as a dict where:
+
+ - Each key is a tuple of two values, the FROM and TO states respectively.
+ - Each value is a callable transition condition that return True or False.
+
+ Transition conditions are called once per cycle when in the FROM state. If one of
+ the transition conditions returns True, the transition is executed that cycle. The
+ remaining conditions aren't called.
+
+ Consider using an OrderedDict if order matters.
+
+ Only one transition may occur per cycle. Every cycle will, at the very least,
+ trigger an in_state event against the current state.
+
+ .. seealso:: See :meth:`~StateMachine.doProcess` for details.
+ """
+
+ def __init__(self, cfg, context=None) -> None:
+ super(StateMachine, self).__init__()
+
+ self._set_logging_context(context)
+
+ self._state = None # We start outside of any state, first cycle enters initial state
+ self._handler = {} # Nested dict mapping [state][event] = handler
+ self._transition = {} # Dict mapping [from_state] = [ (to_state, transition), ... ]
+ self._prefix = { # Default prefixes used when calling handler functions by name
+ "on_entry": "_on_entry_",
+ "in_state": "_in_state_",
+ "on_exit": "_on_exit_",
+ }
+
+ # Specifying an initial state is not optional
+ if "initial" not in cfg:
+ raise StateMachineException(
+ "StateMachine configuration must include " "'initial' to specify starting state."
+ )
+ self._initial = cfg["initial"]
+ self._set_handlers(self._initial)
+
+ self._setup_state_handlers(cfg.get("states", {}), context)
+ self._setup_transition_handlers(cfg.get("transitions", {}), context)
+
+ def _setup_state_handlers(self, state_handler_configuration, context) -> None:
+ """
+ This method constructs the state handlers from a user-provided dict.
+
+ :param state_handler_configuration: Dictionary with state handler
+ definitions.
+ :param context: Context is provided to state handlers that inherit
+ from HasContext.
+ """
+ for state_name, handlers in state_handler_configuration.items():
+ if isinstance(handlers, HasContext):
+ handlers.set_context(context)
+
+ try:
+ if isinstance(handlers, State):
+ self._set_handlers(
+ state_name,
+ handlers.on_entry,
+ handlers.in_state,
+ handlers.on_exit,
+ )
+ elif isinstance(handlers, dict):
+ self._set_handlers(state_name, **handlers)
+ elif hasattr(handlers, "__iter__"):
+ self._set_handlers(state_name, *handlers)
+ else:
+ raise RuntimeError("Handler is not State, dict or __iter__.")
+ except Exception:
+ raise StateMachineException(
+ "Failed to parse state handlers for state '%s'. "
+ "Must be dict or iterable." % state_name
+ )
+
+ def _setup_transition_handlers(self, transition_handler_configuration, context) -> None:
+ """
+ This method constructs the transition handlers from a user-provided
+ dict.
+
+ :param transition_handler_configuration: Dictionary with transition
+ handler definitions.
+ :param context: Context is provided to transition handlers that inherit
+ from HasContext.
+ """
+ for states, check_func in transition_handler_configuration.items():
+ from_state, to_state = states
+
+ # Set up default handlers if this state hasn't been mentioned before
+ if from_state not in self._handler:
+ self._set_handlers(from_state)
+ if to_state not in self._handler:
+ self._set_handlers(to_state)
+
+ if isinstance(check_func, HasContext):
+ check_func.set_context(context)
+
+ # Set up the transition
+ self._set_transition(from_state, to_state, check_func)
+
+ @property
+ def state(self):
+ """Name of the current state."""
+ return self._state
+
+
+[docs]
+ def can(self, state):
+ """
+ Returns true if the transition to 'state' is allowed from the current state.
+
+ :param state: State to check transition to
+ :return: True if state is reachable from current
+ """
+ if self._state is None:
+ return state == self._initial
+
+ return state in (transition[0] for transition in self._transition[self._state])
+
+
+
+[docs]
+ def bind_handlers_by_name(self, instance, override=False, prefix=None) -> None:
+ """
+ Auto-bind state handlers based on naming convention.
+
+ :param instance: Target object instance to search for handlers and bind events to.
+ :param override: If set to True, matching handlers will replace
+ previously registered handlers.
+ :param prefix: Dict or list of prefixes to override defaults
+ (keys: on_entry, in_state, on_exit)
+
+ This function enables automatically binding state handlers to events without having to
+ specify them in the constructor. When called, this function searches `instance` for
+ member functions that match the following patterns for all known states
+ (states mentioned in 'states' or 'transitions' dicts of cfg):
+
+ - ``instance._on_entry_[state]``
+ - ``instance._in_state_[state]``
+ - ``instance._on_exit_[state]``
+
+ The default prefixes may be overridden using the prefix parameter. Supported keys are
+ 'on_entry', 'in_state', and 'on_exit'. Values should include any and
+ all desired underscores.
+
+ Matching functions are assigned as handlers to the corresponding state events,
+ iff no handler was previously assigned to that event.
+
+ If a state event already had a handler assigned (during construction or previous call
+ to this function), no changes are made even if a matching function is found. To force
+ previously assigned handlers to be overwritten, set the third parameter to True.
+ This may be useful to implement inheritance-like specialization using multiple
+ implementation classes but only one StateMachine instance.
+ """
+ if prefix is None:
+ prefix = {}
+ if not isinstance(prefix, dict) and hasattr(prefix, "__iter__"):
+ prefix = dict(zip(["on_entry", "in_state", "on_exit"], prefix))
+
+ # Merge prefix defaults with any provided prefixes
+ prefix = dict(list(self._prefix.items()) + list(prefix.items()))
+
+ # Bind handlers
+ for state, handlers in self._handler.items():
+ for event, handler in handlers.items():
+ if handler is None or override:
+ named_handler = getattr(instance, prefix[event] + state, None)
+ if callable(named_handler):
+ self._handler[state][event] = named_handler
+
+
+
+[docs]
+ def doProcess(self, dt) -> None:
+ """
+ Process a cycle of this state machine.
+
+ :param dt: Delta T. "Time" passed since last cycle, passed on to event handlers.
+
+ A cycle will perform at most one transition and exactly one in_state event.
+
+ A transition will only occur if one of the transition condition functions leaving
+ the current state returns True.
+
+ When a transition occurs, the following events are raised:
+ - on_exit_old_state()
+ - on_entry_new_state()
+ - in_state_new_state()
+
+ The first cycle after init or reset will never call transition checks and, instead,
+ always performs on_entry and in_state on the initial state.
+
+ Whether a transition occurs or not, and regardless of any other circumstances, a
+ cycle always ends by raising an in_state event on the current (potentially new)
+ state.
+ """
+ # Initial transition on first cycle / after a reset()
+ if self._state is None:
+ self.log.debug('Entering initial state "%s"', self._initial)
+ self._state = self._initial
+ self._raise_event("on_entry", 0)
+ self._raise_event("in_state", 0)
+ return
+
+ # General transition
+ for target_state, check_func in self._transition.get(self._state, []):
+ if check_func():
+ self.log.debug("Transition triggered (%s -> %s)", self._state, target_state)
+ self._raise_event("on_exit", dt)
+ self._state = target_state
+ self._raise_event("on_entry", dt)
+ break
+
+ # Always end with an in_state
+ self._raise_event("in_state", dt)
+
+
+
+[docs]
+ def reset(self) -> None:
+ """
+ Reset the state machine to before the first cycle. The next process() will
+ enter the initial state.
+ """
+ self._state = None
+
+
+ def _set_handlers(self, state, *args, **kwargs) -> None:
+ """
+ Add or update state handlers.
+
+ :param state: Name of state to be added or updated
+ :param on_entry: Handler for on_entry events. May be None, callable, or list of callables.
+ :param in_state: Handler for in_state events. May be None, callable, or list of callables.
+ :param on_exit: Handler for on_exit events. May be None, callable, or list of callables.
+
+ Handlers may take up to one parameter (not counting self), delta T since last cycle,
+ and should return nothing.
+
+ When handlers are omitted or set to None, no event will be raised at all.
+ """
+ # Variable arguments for state handlers
+ # Default to calling target.on_entry_state_name(), etc
+ on_entry = args[0] if len(args) > 0 else kwargs.get("on_entry", None)
+ in_state = args[1] if len(args) > 1 else kwargs.get("in_state", None)
+ on_exit = args[2] if len(args) > 2 else kwargs.get("on_exit", None)
+
+ self._handler[state] = {
+ "on_entry": on_entry,
+ "in_state": in_state,
+ "on_exit": on_exit,
+ }
+
+ def _set_transition(self, from_state, to_state, transition_check) -> None:
+ """
+ Add or update a transition and its condition function.
+
+ :param from_state: Name of state this transition leaves
+ :param to_state: Name of state this transition enters
+ :param transition_check: Callable condition under which this transition occurs.
+ Should return True or False.
+
+ The transition_check function should return True if the transition should occur.
+ Otherwise, False.
+
+ Transition condition functions should take no parameters (not counting self).
+ """
+ if not callable(transition_check):
+ raise StateMachineException("Transition condition must be callable.")
+
+ if from_state not in self._transition.keys():
+ self._transition[from_state] = []
+
+ # Remove previously added transition with same From -> To mapping
+ try:
+ del self._transition[from_state][
+ [x[0] for x in self._transition[from_state]].index(to_state)
+ ]
+ except Exception:
+ pass
+
+ self._transition[from_state].append(
+ (
+ to_state,
+ transition_check,
+ )
+ )
+
+ def _raise_event(self, event, dt) -> None:
+ """
+ Invoke the given event name for the current state, passing dt as a parameter.
+
+ :param event: Name of event to raise on current state.
+ :param dt: Delta T since last cycle.
+ """
+ # May be None, function reference, or list of function refs
+ self.log.debug("Processing state=%s, handler=%s", self._state, event)
+ handlers = self._handler[self._state][event]
+
+ if handlers is None:
+ handlers = []
+
+ if callable(handlers):
+ handlers = [handlers]
+
+ for handler in handlers:
+ try:
+ handler(dt)
+ except TypeError:
+ handler()
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module contains some useful helper classes and functions that are not specific to a certain
+module contained in the Core API.
+"""
+
+import functools
+import importlib
+import inspect
+import textwrap
+from datetime import datetime
+from os import listdir
+from os import path as osp
+from types import ModuleType
+from typing import NoReturn
+
+from lewis.core.exceptions import LewisException, LimitViolationException
+from lewis.core.logging import has_log
+
+
+
+[docs]
+@has_log
+def get_submodules(module) -> dict[str, ModuleType]:
+ """
+ This function imports all sub-modules of the supplied module and returns a dictionary
+ with module names as keys and the sub-module objects as values. If the supplied parameter
+ is not a module object, a RuntimeError is raised.
+
+ :param module: Module object from which to import sub-modules.
+ :return: Dict with name-module pairs.
+ """
+ if not inspect.ismodule(module):
+ raise RuntimeError(
+ "Can only extract submodules from a module object, "
+ "for example imported via importlib.import_module"
+ )
+
+ submodules = get_members(module, inspect.ismodule)
+
+ module_path = list(getattr(module, "__path__", [None]))[0]
+
+ if module_path is not None:
+ for item in listdir(module_path):
+ module_name = extract_module_name(osp.join(module_path, item))
+
+ if module_name is not None:
+ try:
+ submodules[module_name] = importlib.import_module(
+ ".{}".format(module_name), package=module.__name__
+ )
+ except ImportError as import_error:
+ # This is necessary in case random directories are in the path or things can
+ # just not be imported due to other ImportErrors.
+ get_submodules.log.error(
+ "ImportError for {module}: {error}".format(
+ module=module_name, error=import_error
+ )
+ )
+
+ return submodules
+
+
+
+
+[docs]
+def get_members(obj, predicate=None):
+ """
+ Returns all members of an object for which the supplied predicate is true and that do not
+ begin with __. Keep in mind that the supplied function must accept a potentially very broad
+ range of inputs, because the members of an object can be of any type. The function puts
+ those members into a dict with the member names as keys and returns it. If no predicate is
+ supplied, all members are put into the dict.
+
+ :param obj: Object from which to get the members.
+ :param predicate: Filter function for the members, only members for which True is returned are
+ part of the resulting dict.
+ :return: Dict with name-object pairs of members of obj for which predicate returns true.
+ """
+ members = {member: getattr(obj, member) for member in dir(obj) if not member.startswith("__")}
+
+ if predicate is None:
+ return members
+
+ return {name: member for name, member in members.items() if predicate(member)}
+
+
+
+
+[docs]
+def extract_module_name(absolute_path):
+ """
+ This function tries to extract a valid module name from the basename of the supplied path.
+ If it's a directory, the directory name is returned, if it's a file, the file name
+ without extension is returned. If the basename starts with _ or . or it's a file with an
+ ending different from .py, the function returns None
+
+ :param absolute_path: Absolute path of something that might be a module.
+ :return: Module name or None.
+ """
+ base_name = osp.basename(osp.normpath(absolute_path))
+
+ # If the basename starts with _ it's probably __init__.py or __pycache__ or something internal.
+ # At the moment there seems to be no use case for those
+ if base_name[0] in (".", "_"):
+ return None
+
+ # If it's a directory, there's nothing else to check, so it can be returned directly
+ if osp.isdir(absolute_path):
+ return base_name
+
+ module_name, extension = osp.splitext(base_name)
+
+ # If it's a file, it must have a .py ending
+ if extension == ".py":
+ return module_name
+
+ return None
+
+
+
+
+[docs]
+def dict_strict_update(base_dict, update_dict) -> None:
+ """
+ This function updates base_dict with update_dict if and only if update_dict does not contain
+ keys that are not already in base_dict. It is essentially a more strict interpretation of the
+ term "updating" the dict.
+
+ If update_dict contains keys that are not in base_dict, a RuntimeError is raised.
+
+ :param base_dict: The dict that is to be updated. This dict is modified.
+ :param update_dict: The dict containing the new values.
+ """
+ additional_keys = set(update_dict.keys()) - set(base_dict.keys())
+ if len(additional_keys) > 0:
+ raise RuntimeError(
+ "The update dictionary contains keys that are not part of "
+ "the base dictionary: {}".format(str(additional_keys)),
+ additional_keys,
+ )
+
+ base_dict.update(update_dict)
+
+
+
+
+[docs]
+def seconds_since(start):
+ """
+ This is a small helper function that returns the elapsed seconds
+ since start using datetime.datetime.now().
+
+ :param start: Start time.
+ :return: Elapsed seconds since start time.
+ """
+ return (datetime.now() - start).total_seconds()
+
+
+
+
+[docs]
+class FromOptionalDependency:
+ """
+ This is a utility class for importing classes from a module or
+ replacing them with dummy types if the module can not be loaded.
+
+ Assume module 'a' that does:
+
+ .. sourcecode:: Python
+
+ from b import C, D
+
+ and module 'e' which does:
+
+ .. sourcecode:: Python
+
+ from a import F
+
+ where 'b' is a hard to install dependency which is thus optional.
+ To still be able to do:
+
+ .. sourcecode:: Python
+
+ import e
+
+ without raising an error, for example for inspection purposes,
+ this class can be used as a workaround in module 'a':
+
+ .. sourcecode:: Python
+
+ C, D = FromOptionalDependency("b").do_import("C", "D")
+
+ which is not as pretty as the actual syntax, but at least it
+ can be read in a similar way. If the module 'b' can not be imported,
+ stub-types are created that are called 'C' and 'D'. Everything depending
+ on these types will work until any of those are instantiated - in that
+ case an exception is raised.
+
+ The exception can be controlled via the exception-parameter. If it is a
+ string, a LewisException is constructed from it. Alternatively it can
+ be an instance of an exception-type. If not provided, a LewisException
+ with a standard message is constructed. If it is anything else, a RuntimeError
+ is raised.
+
+ Essentially, this class helps deferring ImportErrors until anything from
+ the module that was attempted to load is actually used.
+
+ :param module: Module from that symbols should be imported.
+ :param exception: Text for LewisException or custom exception object.
+ """
+
+ def __init__(self, module, exception=None) -> None:
+ self._module = module
+
+ if exception is None:
+ exception = (
+ "The optional dependency '{}' is required for the "
+ "functionality you tried to use.".format(self._module)
+ )
+
+ if isinstance(exception, str):
+ exception = LewisException(exception)
+
+ if not isinstance(exception, BaseException):
+ raise RuntimeError(
+ "The exception parameter has to be either a string or a an instance of an "
+ "exception type (derived from BaseException)."
+ )
+
+ self._exception = exception
+
+
+[docs]
+ def do_import(self, *names):
+ """
+ Tries to import names from the module specified on initialization
+ of the FromOptionalDependency-object. In case an ImportError occurs,
+ the requested names are replaced with stub objects.
+
+ :param names: List of strings that are used as type names.
+ :return: Tuple of actual symbols or stub types with provided names. If there is only one
+ element in the tuple, that element is returned.
+ """
+ try:
+ module_object = importlib.import_module(self._module)
+
+ objects = tuple(getattr(module_object, name) for name in names)
+ except ImportError:
+
+ def failing_init(obj, *args, **kwargs) -> NoReturn:
+ raise self._exception
+
+ objects = tuple(type(name, (object,), {"__init__": failing_init}) for name in names)
+
+ return objects if len(objects) != 1 else objects[0]
+
+
+
+
+
+[docs]
+def format_doc_text(text):
+ """
+ A very thin wrapper around textwrap.fill to consistently wrap documentation text
+ for display in a command line environment. The text is wrapped to 99 characters with an
+ indentation depth of 4 spaces. Each line is wrapped independently in order to preserve
+ manually added line breaks.
+
+ :param text: The text to format, it is cleaned by inspect.cleandoc.
+ :return: The formatted doc text.
+ """
+
+ return "\n".join(
+ textwrap.fill(line, width=99, initial_indent=" ", subsequent_indent=" ")
+ for line in inspect.cleandoc(text).splitlines()
+ )
+
+
+
+
+[docs]
+class check_limits:
+ """
+ This decorator helps to make sure that the parameter of a property setter (or any other
+ method with one argument) is within certain numerical limits.
+
+ It's possible to set static limits using floats or ints:
+
+ .. sourcecode:: Python
+
+ class Foo:
+ _bar = 0
+
+ @property
+ def bar(self):
+ return self._bar
+
+ @bar.setter
+ @check_limits(0, 15)
+ def bar(self, new_value):
+ self._bar = new_value
+
+ But sometimes this is not flexible enough, so it's also possible to supply strings, which
+ are the names of attributes of the object the decorated method belongs with:
+
+ .. sourcecode:: Python
+
+ class Foo:
+ _bar = 0
+
+ bar_min = 0
+ bar_max = 24
+
+ @property
+ def bar(self):
+ return self._bar
+
+ @bar.setter
+ @check_limits("bar_min", "bar_max")
+ def bar(self, new_value):
+ self._bar = new_value
+
+ This will make sure that the new value is always between ``bar_min`` and ``bar_max``, even
+ if they change at runtime. If the limit is ``None`` (default), the value will not be limited
+ in that direction.
+
+ Upper and lower limit can also be used exclusively, for example for a property that has a lower
+ bound but not an upper, say a temperature:
+
+ .. sourcecode:: Python
+
+ class Foo:
+ _temp = 273.15
+
+ @check_limits(lower=0)
+ def set_temperature(self, t_in_kelvin):
+ self._temp = t_in_kelvin
+
+
+ If the value is outside the specified limits, the decorated function is not called and a
+ :class:`~lewis.core.exceptions.LimitViolationException` is raised if the ``silent``-
+ parameter is ``False`` (default). If that option is active, the call is simply silently
+ ignored.
+
+ :param lower: Numerical lower limit or name of attribute that contains limit.
+ :param upper: Numerical upper limit or name of attribute that contains limit.
+ :param silent: A limit violation will not raise an exception if this option is ``True``.
+ """
+
+ def __init__(self, lower=None, upper=None, silent=False) -> None:
+ self._lower = lower
+ self._upper = upper
+ self._silent = silent
+
+ def __call__(self, f):
+ @functools.wraps(f)
+ def limit_checked(obj, new_value):
+ lower = getattr(obj, self._lower) if isinstance(self._lower, str) else self._lower
+ upper = getattr(obj, self._upper) if isinstance(self._upper, str) else self._upper
+
+ if (lower is None or lower <= new_value) and (upper is None or new_value <= upper):
+ return f(obj, new_value)
+
+ if not self._silent:
+ raise LimitViolationException(
+ "%f is outside limits (%r, %r)" % (new_value, lower, upper)
+ )
+
+ return limit_checked
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+This module contains base classes for devices. Inherit from :class:`Device` for simple devices
+or from :class:`StateMachineDevice` for devices that are more complex and can be described
+using a state machine.
+"""
+
+from logging import Logger
+from typing import Callable
+
+from lewis.core.devices import DeviceBase
+from lewis.core.processor import CanProcess, CanProcessComposite
+from lewis.core.statemachine import State, StateMachine
+from lewis.core.utils import dict_strict_update
+
+
+
+[docs]
+class Device(DeviceBase, CanProcess):
+ """
+ This class exists mainly for consistency. It is meant to implement very simple devices that
+ do not require a state machine for their simulation. For such devices, all that is required
+ is subclassing from `Device` and possibly implementing `doProcess`, but this is optional.
+
+ StateMachineDevice offers more functionality and is more likely to be useful for implementing
+ simulations of real devices.
+ """
+
+ def __init__(self) -> None:
+ super(Device, self).__init__()
+
+
+
+
+[docs]
+class StateMachineDevice(DeviceBase, CanProcessComposite):
+ """
+ This class is intended to be sub-classed to implement devices using a finite state machine
+ internally.
+
+ Implementing such a device is straightforward, there are three methods
+ that *must* be overridden:
+
+ - :meth:`_get_state_handlers`
+ - :meth:`_get_initial_state`
+ - :meth:`_get_transition_handlers`
+
+ The first method is supposed to return a dictionary with state handlers for each state
+ of the state machine, the second method must return the name of the initial state.
+ The third method must return a dict-like object (often an OrderedDict from collections)
+ that defines the conditions for transitions between the states of the state machine.
+
+ They are implemented as methods and not as plain class member variables, because often
+ they use the `self`-variable, which does not exist at the class level.
+
+ From these three methods, a :class:`~lewis.core.statemachine.StateMachine`-instance is
+ constructed, it's available as the device's ``_csm``-member. CSM is short for
+ "cycle-based state machine".
+
+ Most device implementation will also want to override this method:
+
+ - :meth:`_initialize_data`
+
+ This method should initialise device state variables (such as temperature, speed, etc.).
+ Having this in a separate method from ``__init__`` has the advantage that it can be used
+ to reset those variables at a later stage, without having to write the same code again.
+
+ Following this scheme, inheriting from StateMachineDevice also provides the possibility
+ for users of the class to override the states, the transitions, the initial state and
+ even the data. For states, transitions and data, dicts need to be passed to the
+ constructor, for the initial state that should be a string.
+
+ All these overrides can be used to define device setups to describe certain scenarios
+ more easily.
+
+ :param override_states: Dict with one entry per state. Only states defined in the state
+ machine are allowed.
+ :param override_transitions: Dict with (state, state) tuples as keys and
+ callables as values.
+ :param override_initial_state: The initial state.
+ :param override_initial_data: A dict that contains data members
+ that should be overwritten on construction.
+ """
+
+ def __init__(
+ self,
+ override_states: dict[str, State] | None = None,
+ override_transitions: dict[tuple[State, State], Callable[[], bool]] | None = None,
+ override_initial_state: State | None = None,
+ override_initial_data: dict[str, float] | None = None,
+ ) -> None:
+ super(StateMachineDevice, self).__init__()
+
+ self.log: Logger
+ self.log.info("Creating device, setting up state machine")
+
+ self._initialize_data()
+ self._override_data(override_initial_data)
+
+ state_handlers = self._get_final_state_handlers(override_states)
+ initial = override_initial_state or self._get_initial_state()
+
+ if initial not in state_handlers:
+ raise RuntimeError("Initial state '{}' is not a valid state.".format(initial))
+
+ self._csm = StateMachine(
+ {
+ "initial": initial,
+ "states": state_handlers,
+ "transitions": self._get_final_transition_handlers(override_transitions),
+ },
+ context=self,
+ )
+
+ self.add_processor(self._csm)
+
+ def _get_state_handlers(self) -> dict[str, State]:
+ """
+ Implement this method to return a dict-like object with state handlers
+ (see :class:`~lewis.core.statemachine.State`) for each state of the state machine.
+ The default implementation raises a ``NotImplementedError``.
+
+ :return: A dict-like object containing named state handlers.
+ """
+ raise NotImplementedError(
+ "_get_state_handlers must be implemented in a StateMachineDevice."
+ )
+
+ def _get_initial_state(self) -> State:
+ """
+ Implement this method to return the initial state of the internal state machine.
+ The default implementation raises a ``NotImplementedError``.
+
+ :return: The initial state of the state machine.
+ """
+ raise NotImplementedError("_get_initial_state must be implemented in a StateMachineDevice.")
+
+ def _get_transition_handlers(self) -> dict[tuple[State, State], Callable[[], bool]]:
+ """
+ Implement this method to return transition handlers for the internal state machine.
+ The keys should be (state, state)-tuples and the values functions that return true
+ if the transition should be triggered. The default implementation raises a
+ ``NotImplementedError``.
+
+ :return: A dict-like object containing transition handlers.
+ """
+ raise NotImplementedError(
+ "_get_transition_handlers must be implemented in a StateMachineDevice."
+ )
+
+ def _initialize_data(self) -> None:
+ """
+ Implement this method to initialize data members of the device, such as temperature,
+ speed and others. It gets called first in the __init__-method. The default implementation
+ does nothing.
+ """
+ pass
+
+ def _get_final_state_handlers(self, overrides: dict[str, State] | None) -> dict[str, State]:
+ states = self._get_state_handlers()
+
+ if overrides is not None:
+ dict_strict_update(states, overrides)
+
+ return states
+
+ def _get_final_transition_handlers(
+ self, overrides: dict[tuple[State, State], Callable[[], bool]] | None
+ ) -> dict[tuple[State, State], Callable[[], bool]]:
+ transitions = self._get_transition_handlers()
+
+ if overrides is not None:
+ dict_strict_update(transitions, overrides)
+
+ return transitions
+
+ def _override_data(self, overrides: dict[str, float] | None) -> None:
+ """
+ This method overrides data members of the class, but does not allow for adding new members.
+
+ :param overrides: Dict with data overrides.
+ """
+ if overrides is not None:
+ for name, val in overrides.items():
+ self.log.debug("Trying to override initial data (%s=%s)", name, val)
+ if name not in dir(self):
+ raise AttributeError(
+ "Can not override non-existing attribute" "'{}' of class '{}'.".format(
+ name, type(self).__name__
+ )
+ )
+
+ setattr(self, name, val)
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from collections import OrderedDict
+
+from lewis.core.processor import CanProcess
+from lewis.core.statemachine import StateMachine
+from lewis.devices import StateMachineDevice
+
+from . import states
+from .bearings import MagneticBearings
+
+
+
+[docs]
+class SimulatedBearings(CanProcess, MagneticBearings):
+ def __init__(self) -> None:
+ super(SimulatedBearings, self).__init__()
+
+ self._csm = StateMachine(
+ {
+ "initial": "resting",
+ "transitions": {
+ ("resting", "levitating"): lambda: self._levitate,
+ ("levitating", "levitated"): self.levitationComplete,
+ ("levitated", "delevitating"): lambda: not self._levitate,
+ ("delevitating", "resting"): self.delevitationComplete,
+ },
+ }
+ )
+
+ self._levitate = False
+
+ def engage(self) -> None:
+ self.levitate()
+
+ def disengage(self) -> None:
+ self.delevitate()
+
+ def levitate(self) -> None:
+ self._levitate = True
+
+ def delevitate(self) -> None:
+ self._levitate = False
+
+ def levitationComplete(self) -> bool:
+ return True
+
+ def delevitationComplete(self) -> bool:
+ return True
+
+ def doProcess(self, dt) -> None:
+ self._csm.process(dt)
+
+ @property
+ def ready(self):
+ return self._csm.state == "levitated" and self._levitate
+
+ @property
+ def idle(self):
+ return self._csm.state == "resting" and not self._levitate
+
+
+
+
+[docs]
+class SimulatedChopper(StateMachineDevice):
+ _bearings = None
+
+ def _initialize_data(self) -> None:
+ self.speed = 0.0
+ self.target_speed = 0.0
+
+ self.phase = 0.0
+ self.target_phase = 0.0
+
+ self.parking_position = 0.0
+ self.target_parking_position = 0.0
+ self.auto_park = False
+
+ self._park_commanded = False
+ self._stop_commanded = False
+ self._start_commanded = False
+ self._idle_commanded = False
+ self._phase_commanded = False
+ self._shutdown_commanded = False
+ self._initialized = False
+
+ if self._bearings is None:
+ self._bearings = SimulatedBearings()
+
+ def _get_state_handlers(self):
+ return {
+ "init": states.DefaultInitState(),
+ "bearings": {"in_state": self._bearings},
+ "stopped": states.DefaultStoppedState(),
+ "stopping": states.DefaultStoppingState(),
+ "accelerating": states.DefaultAcceleratingState(),
+ "phase_locking": states.DefaultPhaseLockingState(),
+ "phase_locked": states.DefaultPhaseLockedState(),
+ "idle": states.DefaultIdleState(),
+ "parking": states.DefaultParkingState(),
+ "parked": states.DefaultParkedState(),
+ }
+
+ def _get_initial_state(self) -> str:
+ return "init"
+
+ def _get_transition_handlers(self):
+ return OrderedDict(
+ [
+ (("init", "bearings"), lambda: self.initialized),
+ (("bearings", "stopped"), lambda: self._bearings.ready),
+ (("bearings", "init"), lambda: self._bearings.idle),
+ (
+ ("parking", "parked"),
+ lambda: self.parking_position == self.target_parking_position,
+ ),
+ (("parking", "stopping"), lambda: self._stop_commanded),
+ (("parked", "stopping"), lambda: self._stop_commanded),
+ (("parked", "accelerating"), lambda: self._start_commanded),
+ (("stopped", "accelerating"), lambda: self._start_commanded),
+ (("stopped", "parking"), lambda: self._park_commanded),
+ (("stopped", "bearings"), lambda: self._shutdown_commanded),
+ (("accelerating", "stopping"), lambda: self._stop_commanded),
+ (("accelerating", "idle"), lambda: self._idle_commanded),
+ (
+ ("accelerating", "phase_locking"),
+ lambda: self.speed == self.target_speed,
+ ),
+ (("idle", "accelerating"), lambda: self._start_commanded),
+ (("idle", "stopping"), lambda: self._stop_commanded),
+ (("phase_locking", "stopping"), lambda: self._stop_commanded),
+ (
+ ("phase_locking", "phase_locked"),
+ lambda: self.phase == self.target_phase,
+ ),
+ (("phase_locking", "idle"), lambda: self._idle_commanded),
+ (("phase_locked", "accelerating"), lambda: self._start_commanded),
+ (("phase_locked", "phase_locking"), lambda: self._phase_commanded),
+ (("phase_locked", "stopping"), lambda: self._stop_commanded),
+ (("phase_locked", "idle"), lambda: self._idle_commanded),
+ (("stopping", "accelerating"), lambda: self._start_commanded),
+ (("stopping", "stopped"), lambda: self.speed == 0.0),
+ (("stopping", "idle"), lambda: self._idle_commanded),
+ ]
+ )
+
+ @property
+ def state(self):
+ """
+ The current state of the chopper. This parameter is read-only, it is
+ determined by the internal state machine of the device.
+ """
+ return self._csm.state
+
+ @property
+ def initialized(self):
+ return self._initialized
+
+ def initialize(self) -> None:
+ if self._csm.can("bearings") and not self.initialized:
+ self._initialized = True
+ self._bearings.engage()
+
+ def deinitialize(self) -> None:
+ if self._csm.can("bearings") and self.initialized:
+ self._shutdown_commanded = True
+ self._bearings.disengage()
+
+ def park(self) -> None:
+ if self._csm.can("parking"):
+ self._park_commanded = True
+
+ @property
+ def parked(self):
+ return self._csm.state == "parked"
+
+ def stop(self) -> None:
+ if self._csm.can("stopping"):
+ self._stop_commanded = True
+
+ @property
+ def stopped(self):
+ return self._csm.state == "stopped"
+
+ def start(self) -> None:
+ if self._csm.can("accelerating") and self.target_speed > 0.0:
+ self._start_commanded = True
+ else:
+ self.stop()
+
+ @property
+ def started(self):
+ return self._csm.state == "accelerating"
+
+ def unlock(self) -> None:
+ if self._csm.can("idle"):
+ self._idle_commanded = True
+
+ @property
+ def idle(self):
+ return self._csm.state == "idle"
+
+ def lock_phase(self) -> None:
+ if self._csm.can("phase_locking"):
+ self._phase_commanded = True
+
+ @property
+ def phase_locked(self):
+ return self._csm.state == "phase_locked"
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.core import approaches
+from lewis.core.statemachine import State
+
+
+
+
+
+
+
+[docs]
+class DefaultParkingState(State):
+ def __init__(self, parking_speed=5.0) -> None:
+ super(DefaultParkingState, self).__init__()
+ self._parking_speed = parking_speed
+
+
+[docs]
+ def in_state(self, dt) -> None:
+ self._context.parking_position = approaches.linear(
+ self._context.parking_position,
+ self._context.target_parking_position,
+ self._parking_speed,
+ dt,
+ )
+
+
+
+
+
+
+
+
+
+
+
+
+[docs]
+class DefaultStoppingState(State):
+ def __init__(self, acceleration=5.0) -> None:
+ super(DefaultStoppingState, self).__init__()
+ self._acceleration = acceleration
+
+
+[docs]
+ def in_state(self, dt) -> None:
+ self._context.speed = approaches.linear(self._context.speed, 0.0, self._acceleration, dt)
+
+
+
+
+
+
+
+
+[docs]
+class DefaultStoppedState(State):
+
+[docs]
+ def on_entry(self, dt) -> None:
+ if self._context.auto_park:
+ self._context._park_commanded = True
+
+
+
+
+
+[docs]
+class DefaultIdleState(State):
+ def __init__(self, acceleration=0.05) -> None:
+ super(DefaultIdleState, self).__init__()
+ self._acceleration = acceleration
+
+
+[docs]
+ def in_state(self, dt) -> None:
+ self._context.speed = approaches.linear(
+ self._context.speed, self._context.target_speed, self._acceleration, dt
+ )
+
+
+
+
+def on_entry(self, dt) -> None:
+ self._context._idle_commanded = False
+
+
+
+[docs]
+class DefaultAcceleratingState(State):
+ def __init__(self, acceleration=5.0) -> None:
+ super(DefaultAcceleratingState, self).__init__()
+ self._acceleration = acceleration
+
+
+[docs]
+ def in_state(self, dt) -> None:
+ self._context.speed = approaches.linear(
+ self._context.speed, self._context.target_speed, self._acceleration, dt
+ )
+
+
+
+
+
+
+
+
+[docs]
+class DefaultPhaseLockingState(State):
+ def __init__(self, phase_locking_speed=5.0) -> None:
+ super(DefaultPhaseLockingState, self).__init__()
+ self._phase_locking_speed = phase_locking_speed
+
+
+[docs]
+ def in_state(self, dt) -> None:
+ self._context.phase = approaches.linear(
+ self._context.phase,
+ self._context.target_phase,
+ self._phase_locking_speed,
+ dt,
+ )
+
+
+
+
+
+
+
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.adapters.epics import PV, EpicsInterface
+
+
+
+[docs]
+class ChopperEpicsInterface(EpicsInterface):
+ """
+ ESS chopper EPICS interface
+
+ Interaction with this interface should happen via ChannelAccess (CA). The PV-names
+ usually carry a prefix which depends on the concrete device and environment, so
+ it is omitted in this description. The dynamically generated description of the PVs
+ does however contain the prefix so that the names can be copy-pasted easily.
+
+ The first step is to initialize the chopper, for example via caput on the command line:
+
+ $ caput CmdS init
+
+ After this, the chopper is in a state where it can be started:
+
+ $ caget State
+ State stopped
+
+ To set a specific speed and phase, the setpoints have to be configured via caput:
+
+ $ caput Spd 100
+ $ caput Phs 34.5
+
+ Then the chopper can be commanded to move towards those values:
+
+ $ caput CmdS start
+
+ Now the disc accelerates to the setpoints, the state should now be different:
+
+ $ caget State
+ State accelerating
+
+ The possible commands are part of the PV-specific documentation.
+ """
+
+ pvs = {
+ "Spd-RB": PV(
+ "target_speed",
+ read_only=True,
+ doc="Readback value of the speed setpoint in Hz.",
+ ),
+ "Spd": PV("target_speed", doc="Speed setpoint in Hz."),
+ "ActSpd": PV(
+ "speed",
+ read_only=True,
+ doc="Current rotation speed of the chopper disc in Hz.",
+ ),
+ "Phs-RB": PV(
+ "target_phase",
+ read_only=True,
+ doc="Readback value of phase setpoint in degrees.",
+ ),
+ "Phs": PV("target_phase", doc="Phase setpoint in degrees."),
+ "ActPhs": PV("phase", read_only=True, doc="Current phase of the chopper disc in degrees."),
+ "ParkAng-RB": PV(
+ "target_parking_position",
+ read_only=True,
+ doc="Readback value of the discs parking position setpoint in degrees.",
+ ),
+ "ParkAng": PV(
+ "target_parking_position",
+ doc="The discs parking position setpoint in degrees.",
+ ),
+ "AutoPark": PV(
+ "auto_park",
+ doc="If enabled, the chopper disc will be moved to the parking "
+ "position automatically when the speed is 0 or the chopper "
+ "is otherwise stopped. 0 means False, 1 means True, the string "
+ 'representations of the enum values are "false" and "true".',
+ type="enum",
+ enums=["false", "true"],
+ ),
+ "State": PV("state", read_only=True, type="string"),
+ "CmdS": PV("execute_command", type="string"),
+ "CmdL": PV("last_command", type="string", read_only=True),
+ }
+
+ _commands = {
+ "start": "start",
+ "stop": "stop",
+ "set_phase": "lock_phase",
+ "unlock": "unlock",
+ "park": "park",
+ "init": "initialize",
+ "deinit": "deinitialize",
+ }
+
+ _last_command = ""
+
+ @property
+ def execute_command(self) -> str:
+ """
+ Command to execute. Possible commands are start, stop, set_phase,
+ unlock, park, init, deinit.
+ """
+ return ""
+
+ @execute_command.setter
+ def execute_command(self, value) -> None:
+ command = self._commands.get(value)
+
+ getattr(self.device, command)()
+ self._last_command = command
+
+ @property
+ def last_command(self):
+ """
+ The last command that was executed successfully.
+ """
+ return self._last_command
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from collections import OrderedDict
+
+from lewis.core.utils import check_limits
+from lewis.devices import StateMachineDevice
+
+from . import states
+
+
+
+[docs]
+class SimulatedJulabo(StateMachineDevice):
+ internal_p = 0.1 # The proportional
+ internal_i = 3 # The integral
+ internal_d = 0 # The derivative
+ external_p = 0.1 # The proportional
+ external_i = 3 # The integral
+ external_d = 0 # The derivative
+ temperature_low_limit = 0.0 # Usually set in the hardware
+ temperature_high_limit = 100.0 # Usually set in the hardware
+ set_point_temperature = 24.0 # Set point starts equal to the current temperature
+ heating_power = 5.0 # The heating power
+ version = "JULABO FP50_MH Simulator, ISIS"
+ status = "Hello from the simulated Julabo"
+ is_circulating = 0 # 0 for off, 1 for on
+ temperature = 24.0 # Current temperature in C
+ external_temperature = 26.0 # External temperature in C
+ circulate_commanded = False
+ temperature_ramp_rate = 5.0 # Guessed value in C/min
+
+ def _initialize_data(self) -> None:
+ """
+ This method is called once on construction. After that, it may be
+ manually called again to reset the device to its default state.
+
+ After the first call during construction, the class is frozen.
+
+ This means that attempting to define a new member variable will
+ raise an exception. This is to prevent typos from inadvertently
+ and silently adding new members instead of accessing existing ones.
+ """
+ pass
+
+ def _get_state_handlers(self):
+ return {
+ "circulate": states.DefaultCirculatingState(),
+ "not_circulate": states.DefaultNotCirculatingState(),
+ }
+
+ def _get_initial_state(self) -> str:
+ return "not_circulate"
+
+ def _get_transition_handlers(self):
+ return OrderedDict(
+ [
+ (("not_circulate", "circulate"), lambda: self.circulate_commanded),
+ (("circulate", "not_circulate"), lambda: not self.circulate_commanded),
+ ]
+ )
+
+
+[docs]
+ def set_set_point(self, param) -> str:
+ """
+ Sets the target temperature.
+
+ :param param: The new temperature in C. Must be positive.
+ :return: Empty string.
+ """
+ if self.temperature_low_limit <= param <= self.temperature_high_limit:
+ self.set_point_temperature = param
+ return ""
+
+
+
+[docs]
+ def set_circulating(self, param) -> str:
+ """
+ Sets whether to circulate - in effect whether the heater is on.
+
+ :param param: The mode to set, must be 0 or 1.
+ :return: Empty string.
+ """
+ if param == 0:
+ self.is_circulating = param
+ self.circulate_commanded = False
+ elif param == 1:
+ self.is_circulating = param
+ self.circulate_commanded = True
+ return ""
+
+
+
+[docs]
+ @check_limits(0.1, 99.9)
+ def set_internal_p(self, param) -> str:
+ """
+ Sets the internal proportional.
+ Xp in Julabo speak.
+
+ :param param: The value to set, must be between 0.1 and 99.9
+ :return: Empty string.
+ """
+ self.internal_p = param
+ return ""
+
+
+
+[docs]
+ @check_limits(3, 9999)
+ def set_internal_i(self, param) -> str:
+ """
+ Sets the internal integral.
+ Tn in Julabo speak.
+
+ :param param: The value to set, must be an integer between 3 and 9999
+ :return: Empty string.
+ """
+ self.internal_i = param
+ return ""
+
+
+
+[docs]
+ @check_limits(0, 999)
+ def set_internal_d(self, param) -> str:
+ """
+ Sets the internal derivative.
+ Tv in Julabo speak.
+
+ :param param: The value to set, must be an integer between 0 and 999
+ :return: Empty string.
+ """
+ self.internal_d = param
+ return ""
+
+
+
+[docs]
+ @check_limits(0.1, 99.9)
+ def set_external_p(self, param) -> str:
+ """
+ Sets the external proportional.
+ Xp in Julabo speak.
+
+ :param param: The value to set, must be between 0.1 and 99.9
+ :return: Empty string.
+ """
+ self.external_p = param
+ return ""
+
+
+
+[docs]
+ @check_limits(3, 9999)
+ def set_external_i(self, param) -> str:
+ """
+ Sets the external integral.
+ Tn in Julabo speak.
+
+ :param param: The value to set, must be an integer between 3 and 9999
+ :return: Empty string.
+ """
+ self.external_i = param
+ return ""
+
+
+
+[docs]
+ @check_limits(0, 999)
+ def set_external_d(self, param) -> str:
+ """
+ Sets the external derivative.
+ Tv in Julabo speak.
+
+ :param param: The value to set, must be an integer between 0 and 999
+ :return: Empty string.
+ """
+ self.external_d = param
+ return ""
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.core import approaches
+from lewis.core.statemachine import State
+
+
+
+
+
+
+
+[docs]
+class DefaultCirculatingState(State):
+
+[docs]
+ def in_state(self, dt) -> None:
+ # Approach target temperature at a set rate
+ self._context.temperature = approaches.linear(
+ self._context.temperature,
+ self._context.set_point_temperature,
+ self._context.heating_power / 60.0,
+ dt,
+ )
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.adapters.stream import Cmd, StreamInterface, Var
+
+
+
+[docs]
+class JulaboStreamInterfaceV1(StreamInterface):
+ """Julabos can have different commands sets depending on the version number of the hardware.
+
+ This protocol matches that for: FP50_MH (confirmed).
+ """
+
+ protocol = "julabo-version-1"
+
+ commands = {
+ Var("temperature", read_pattern="^IN_PV_00$", doc="The bath temperature."),
+ Var(
+ "external_temperature",
+ read_pattern="^IN_PV_01$",
+ doc="The external temperature.",
+ ),
+ Var("heating_power", read_pattern="^IN_PV_02$", doc="The heating power."),
+ Var(
+ "set_point_temperature",
+ read_pattern="^IN_SP_00$",
+ doc="The temperature setpoint.",
+ ),
+ Cmd(
+ "set_set_point",
+ r"^OUT_SP_00 ([0-9]*\.?[0-9]+)$",
+ argument_mappings=(float,),
+ ),
+ Var(
+ "temperature_high_limit",
+ read_pattern="^IN_SP_01$",
+ doc="The high limit - usually set in the hardware.",
+ ),
+ Var(
+ "temperature_low_limit",
+ read_pattern="^IN_SP_02$",
+ doc="The low limit - usually set in the hardware.",
+ ),
+ Var("version", read_pattern="^VERSION$", doc="The Julabo version."),
+ Var("status", read_pattern="^STATUS$", doc="The Julabo status."),
+ Var(
+ "is_circulating",
+ read_pattern="^IN_MODE_05$",
+ doc="Whether it is circulating.",
+ ),
+ Cmd("set_circulating", "^OUT_MODE_05 (0|1)$", argument_mappings=(int,)),
+ Var("internal_p", read_pattern="^IN_PAR_06$", doc="The internal proportional."),
+ Cmd(
+ "set_internal_p",
+ r"^OUT_PAR_06 ([0-9]*\.?[0-9]+)$",
+ argument_mappings=(float,),
+ ),
+ Var("internal_i", read_pattern="^IN_PAR_07$", doc="The internal integral."),
+ Cmd("set_internal_i", "^OUT_PAR_07 ([0-9]*)$", argument_mappings=(int,)),
+ Var("internal_d", read_pattern="^IN_PAR_08$", doc="The internal derivative."),
+ Cmd("set_internal_d", "^OUT_PAR_08 ([0-9]*)$", argument_mappings=(int,)),
+ Var("external_p", read_pattern="^IN_PAR_09$", doc="The external proportional."),
+ Cmd(
+ "set_external_p",
+ r"^OUT_PAR_09 ([0-9]*\.?[0-9]+)$",
+ argument_mappings=(float,),
+ ),
+ Var("external_i", read_pattern="^IN_PAR_11$", doc="The external integral."),
+ Cmd("set_external_i", "^OUT_PAR_11 ([0-9]*)$", argument_mappings=(int,)),
+ Var("external_d", read_pattern="^IN_PAR_12$", doc="The external derivative."),
+ Cmd("set_external_d", "^OUT_PAR_12 ([0-9]*)$", argument_mappings=(int,)),
+ }
+
+ in_terminator = "\r"
+ out_terminator = "\r\n"
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.adapters.stream import Cmd, StreamInterface, Var
+
+
+
+[docs]
+class JulaboStreamInterfaceV2(StreamInterface):
+ """Julabos can have different commands sets depending on the version number of the hardware.
+
+ This protocol matches that for: FP50-HE (unconfirmed).
+ """
+
+ protocol = "julabo-version-2"
+
+ commands = {
+ Var("temperature", read_pattern="^IN_PV_00$", doc="The bath temperature."),
+ Var(
+ "external_temperature",
+ read_pattern="^IN_PV_01$",
+ doc="The external temperature.",
+ ),
+ Var("heating_power", read_pattern="^IN_PV_02$", doc="The heating power."),
+ Var(
+ "set_point_temperature",
+ read_pattern="^IN_SP_00$",
+ doc="The temperature setpoint.",
+ ),
+ Cmd(
+ "set_set_point",
+ r"^OUT_SP_00 ([0-9]*\.?[0-9]+)$",
+ argument_mappings=(float,),
+ ),
+ # Read pattern for high limit is different from version 1
+ Var(
+ "temperature_high_limit",
+ read_pattern="^IN_SP_03$",
+ doc="The high limit - usually set in the hardware.",
+ ),
+ # Read pattern for low limit is different from version 1
+ Var(
+ "temperature_low_limit",
+ read_pattern="^IN_SP_04$",
+ doc="The low limit - usually set in the hardware.",
+ ),
+ Var("version", read_pattern="^VERSION$", doc="The Julabo version."),
+ Var("status", read_pattern="^STATUS$", doc="The Julabo status."),
+ Var(
+ "is_circulating",
+ read_pattern="^IN_MODE_05$",
+ doc="Whether it is circulating.",
+ ),
+ Cmd("set_circulating", "^OUT_MODE_05 (0|1)$", argument_mappings=(int,)),
+ Var("internal_p", read_pattern="^IN_PAR_06$", doc="The internal proportional."),
+ Cmd(
+ "set_internal_p",
+ r"^OUT_PAR_06 ([0-9]*\.?[0-9]+)$",
+ argument_mappings=(float,),
+ ),
+ Var("internal_i", read_pattern="^IN_PAR_07$", doc="The internal integral."),
+ Cmd("set_internal_i", "^OUT_PAR_07 ([0-9]*)$", argument_mappings=(int,)),
+ Var("internal_d", read_pattern="^IN_PAR_08$", doc="The internal derivative."),
+ Cmd("set_internal_d", "^OUT_PAR_08 ([0-9]*)$", argument_mappings=(int,)),
+ Var("external_p", read_pattern="^IN_PAR_09$", doc="The external proportional."),
+ Cmd(
+ "set_external_p",
+ r"^OUT_PAR_09 ([0-9]*\.?[0-9]+)$",
+ argument_mappings=(float,),
+ ),
+ Var("external_i", read_pattern="^IN_PAR_11$", doc="The external integral."),
+ Cmd("set_external_i", "^OUT_PAR_11 ([0-9]*)$", argument_mappings=(int,)),
+ Var("external_d", read_pattern="^IN_PAR_12$", doc="The external derivative."),
+ Cmd("set_external_d", "^OUT_PAR_12 ([0-9]*)$", argument_mappings=(int,)),
+ }
+
+ in_terminator = "\r"
+ out_terminator = "\n" # Different from version 1
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from collections import OrderedDict
+
+from lewis.devices import StateMachineDevice
+
+from . import states
+
+
+
+[docs]
+class SimulatedLinkamT95(StateMachineDevice):
+ def _initialize_data(self) -> None:
+ """
+ This method is called once on construction. After that, it may be
+ manually called again to reset the device to its default state.
+
+ After the first call during construction, the class is frozen.
+
+ This means that attempting to define a new member variable will
+ raise an exception. This is to prevent typos from inadvertently
+ and silently adding new members instead of accessing existing ones.
+ """
+ self.serial_command_mode = False
+ self.pump_overspeed = False
+
+ self.start_commanded = False
+ self.stop_commanded = False
+ self.hold_commanded = False
+
+ # Real device remembers values from last run, we use arbitrary defaults
+ self.temperature_rate = 5.0 # Rate of change of temperature in C/min
+ self.temperature_limit = 0.0 # Target temperature in C
+
+ self.pump_speed = 0 # Pump speed in arbitrary unit, ranging 0 to 30
+ self.temperature = 24.0 # Current temperature in C
+
+ self.pump_manual_mode = False
+ self.manual_target_speed = 0
+
+ def _get_state_handlers(self):
+ return {
+ "init": states.DefaultInitState(),
+ "stopped": states.DefaultStoppedState(),
+ "started": states.DefaultStartedState(),
+ "heat": states.DefaultHeatState(),
+ "hold": states.DefaultHoldState(),
+ "cool": states.DefaultCoolState(),
+ }
+
+ def _get_initial_state(self) -> str:
+ return "init"
+
+ def _get_transition_handlers(self):
+ return OrderedDict(
+ [
+ (("init", "stopped"), lambda: self.serial_command_mode),
+ (("stopped", "started"), lambda: self.start_commanded),
+ (("started", "stopped"), lambda: self.stop_commanded),
+ (
+ ("started", "heat"),
+ lambda: self.temperature < self.temperature_limit,
+ ),
+ (
+ ("started", "hold"),
+ lambda: self.temperature == self.temperature_limit,
+ ),
+ (
+ ("started", "cool"),
+ lambda: self.temperature > self.temperature_limit,
+ ),
+ (
+ ("heat", "hold"),
+ lambda: self.temperature == self.temperature_limit or self.hold_commanded,
+ ),
+ (("heat", "cool"), lambda: self.temperature > self.temperature_limit),
+ (("heat", "stopped"), lambda: self.stop_commanded),
+ (
+ ("hold", "heat"),
+ lambda: self.temperature < self.temperature_limit and not self.hold_commanded,
+ ),
+ (
+ ("hold", "cool"),
+ lambda: self.temperature > self.temperature_limit and not self.hold_commanded,
+ ),
+ (("hold", "stopped"), lambda: self.stop_commanded),
+ (("cool", "heat"), lambda: self.temperature < self.temperature_limit),
+ (
+ ("cool", "hold"),
+ lambda: self.temperature == self.temperature_limit or self.hold_commanded,
+ ),
+ (("cool", "stopped"), lambda: self.stop_commanded),
+ ]
+ )
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.core import approaches
+from lewis.core.statemachine import State
+
+
+
+
+
+
+
+[docs]
+class DefaultStoppedState(State):
+
+[docs]
+ def on_entry(self, dt) -> None:
+ # Reset the stop commanded flag once we enter the stopped state
+ self._context.stop_commanded = False
+
+
+
+
+
+[docs]
+class DefaultStartedState(State):
+
+[docs]
+ def on_entry(self, dt) -> None:
+ # Reset the start commanded flag once we enter the started state
+ self._context.start_commanded = False
+
+
+
+
+
+[docs]
+class DefaultHeatState(State):
+
+[docs]
+ def in_state(self, dt) -> None:
+ # Approach target temperature at set temperature rate
+ self._context.temperature = approaches.linear(
+ self._context.temperature,
+ self._context.temperature_limit,
+ self._context.temperature_rate / 60.0,
+ dt,
+ )
+
+
+
+
+
+
+
+
+
+[docs]
+class DefaultCoolState(State):
+
+[docs]
+ def in_state(self, dt) -> None:
+ # TODO: Does manual control work like this? Or is it perhaps a separate state?
+ if self._context.pump_manual_mode:
+ self._context.pump_speed = self._context.manual_target_speed
+ else:
+ # TODO: Figure out real correlation
+ self._context.pump_speed = 30 * (self._context.temperature_rate / 50.0)
+
+ # Handle "cooling too fast" error
+ if self._context.pump_speed > 30:
+ self._context.pump_speed = 30
+ self._context.pump_overspeed = True
+ else:
+ self._context.pump_speed = int(self._context.pump_speed)
+ self._context.pump_overspeed = False
+
+ # Approach target temperature at set temperature rate
+ # TODO: Should be based on pump speed somehow
+ self._context.temperature = approaches.linear(
+ self._context.temperature,
+ self._context.temperature_limit,
+ self._context.temperature_rate / 60.0,
+ dt,
+ )
+
+
+
+[docs]
+ def on_exit(self, dt) -> None:
+ # If we exit the cooling state, the cooling pump should no longer run
+ self._context.pump_overspeed = False
+ self._context.pump_speed = 0
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.adapters.stream import Cmd, StreamInterface
+from lewis.core.logging import has_log
+
+
+
+[docs]
+@has_log
+class LinkamT95StreamInterface(StreamInterface):
+ """
+ Linkam T95 TCP stream interface.
+
+ This is the interface of a simulated Linkam T95 device. The device listens on a configured
+ host:port-combination, one option to connect to it is via telnet:
+
+ $ telnet host port
+
+ Once connected, it's possible to send the specified commands, described in the dynamically
+ generated documentation. Information about host, port and line terminators in the concrete
+ device instance are also generated dynamically.
+ """
+
+ out_terminator = b"\r"
+
+ commands = {
+ Cmd("get_status", "^T$", return_mapping=lambda x: x),
+ Cmd("set_rate", "^R1([0-9]+)$"),
+ Cmd("set_limit", "^L1([0-9]+)$"),
+ Cmd("start", "^S$"),
+ Cmd("stop", "^E$"),
+ Cmd("hold", "^O$"),
+ Cmd("heat", "^H$"),
+ Cmd("cool", "^C$"),
+ Cmd("pump_command", "^P(a0|m0|[0123456789:;<=>?@ABCDEFGHIJKLMN]{1})$"),
+ }
+
+
+[docs]
+ def get_status(self):
+ """
+ Models "T Command" functionality of device.
+
+ Returns all available status information about the device as single byte array.
+
+ :return: Byte array consisting of 10 status bytes.
+ """
+
+ # "The first command sent must be a 'T' command" from T95 manual
+ self.device.serial_command_mode = True
+
+ Tarray = [0x80] * 10
+
+ # Status byte (SB1)
+ Tarray[0] = {
+ "stopped": 0x01,
+ "heat": 0x10,
+ "cool": 0x20,
+ "hold": 0x30,
+ }.get(self.device._csm.state, 0x01)
+
+ if Tarray[0] == 0x30 and self.device.hold_commanded:
+ Tarray[0] = 0x50
+
+ # Error status byte (EB1)
+ if self.device.pump_overspeed:
+ Tarray[1] |= 0x01
+ # TODO: Add support for other error conditions?
+
+ # Pump status byte (PB1)
+ Tarray[2] = 0x80 + self.device.pump_speed
+
+ # Temperature
+ Tarray[6:10] = [ord(x) for x in "%04x" % (int(self.device.temperature * 10) & 0xFFFF)]
+
+ return bytes(Tarray)
+
+
+
+[docs]
+ def set_rate(self, param) -> bytes:
+ """
+ Models "Rate Command" functionality of device.
+
+ Sets the target rate of temperature change.
+
+ :param param: Rate of temperature change in C/min, multiplied by 100, as a string. Must be positive.
+ :return: Empty string.
+ """
+ # TODO: Is not having leading zeroes / 4 digits an error?
+ rate = int(param)
+ if 1 <= rate <= 15000:
+ self.device.temperature_rate = rate / 100.0
+ return b""
+
+
+
+[docs]
+ def set_limit(self, param) -> bytes:
+ """
+ Models "Limit Command" functionality of device.
+
+ Sets the target temperate to be reached.
+
+ :param param: Target temperature in C, multiplied by 10, as a string. Can be negative.
+ :return: Empty string.
+ """
+ # TODO: Is not having leading zeroes / 4 digits an error?
+ limit = int(param)
+ if -2000 <= limit <= 6000:
+ self.device.temperature_limit = limit / 10.0
+ return b""
+
+
+
+[docs]
+ def start(self) -> bytes:
+ """
+ Models "Start Command" functionality of device.
+
+ Tells the T95 unit to start heating or cooling at the rate specified by setRate and to a
+ limit set by setLimit.
+
+ :return: Empty string.
+ """
+ self.device.start_commanded = True
+ return b""
+
+
+
+[docs]
+ def stop(self) -> bytes:
+ """
+ Models "Stop Command" functionality of device.
+
+ Tells the T95 unit to stop heating or cooling.
+
+ :return: Empty string.
+ """
+ self.device.stop_commanded = True
+ return b""
+
+
+
+[docs]
+ def hold(self) -> bytes:
+ """
+ Models "Hold Command" functionality of device.
+
+ Device will hold current temperature until a heat or cool command is issued.
+
+ :return: Empty string.
+ """
+ self.device.hold_commanded = True
+ return b""
+
+
+
+[docs]
+ def heat(self) -> bytes:
+ """
+ Models "Heat Command" functionality of device.
+
+ :return: Empty string.
+ """
+ # TODO: Is this really all it does?
+ self.device.hold_commanded = False
+ return b""
+
+
+
+[docs]
+ def cool(self) -> bytes:
+ """
+ Models "Cool Command" functionality of device.
+
+ :return: Empty string.
+ """
+ # TODO: Is this really all it does?
+ self.device.hold_commanded = False
+ return b""
+
+
+
+[docs]
+ def pump_command(self, param) -> bytes:
+ """
+ Models "LNP Pump Commands" functionality of device.
+
+ Switches between automatic or manual pump mode, and adjusts speed when in manual mode.
+
+ :param param: 'a0' for auto, 'm0' for manual, [0-N] for speed.
+ :return:
+ """
+ lookup = b"0123456789:;<=>?@ABCDEFGHIJKLMN"
+
+ if param == b"a0":
+ self.device.pump_manual_mode = False
+ elif param == b"m0":
+ self.device.pump_manual_mode = True
+ elif param in lookup:
+ self.device.manual_target_speed = lookup.index(param)
+ return b""
+
+
+
+[docs]
+ def handle_error(self, request, error) -> None:
+ """
+ If command is not recognised print and error
+
+ Args:
+ request: requested string
+ error: problem
+
+ """
+ self.log.error("An error occurred at request " + repr(request) + ": " + repr(error))
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.adapters.epics import PV, EpicsInterface
+from lewis.adapters.stream import StreamInterface, Var
+from lewis.core.utils import check_limits
+from lewis.devices import Device
+
+
+
+[docs]
+class VerySimpleDevice(Device):
+ upper_limit = 100
+ lower_limit = 0
+
+ param = 10
+ _second = 2.0
+
+
+
+
+ def set_param(self, new_param) -> None:
+ self.param = int(new_param / 2)
+
+ @property
+ def second(self):
+ """A second (floating point) parameter."""
+ return self._second
+
+ @second.setter
+ @check_limits("lower_limit", "upper_limit")
+ def second(self, new_second) -> None:
+ self._second = new_second
+
+
+
+
+[docs]
+class VerySimpleInterface(EpicsInterface):
+ """
+ This is the EPICS interface to a quite simple device. It offers 5 PVs that expose
+ different things that are part of the device, the interface or neither.
+ """
+
+ pvs = {
+ "Param-Raw": PV("param", type="int", doc="The raw underlying parameter."),
+ "Param": PV(("get_param", "set_param"), type="int"),
+ "Second": PV("second", meta_data_property="param_raw_meta"),
+ "Second-Int": PV("second_int", type="int"),
+ "Constant": PV(lambda: 4, doc="A constant number."),
+ }
+
+ @property
+ def param_raw_meta(self):
+ return {"lolo": self.device.lower_limit, "hihi": self.device.upper_limit}
+
+ @property
+ def second_int(self):
+ """The second parameter as an integer."""
+ return int(self.device.second)
+
+
+
+
+[docs]
+class VerySimpleStreamInterface(StreamInterface):
+ """This is a TCP stream interface to the epics device, which only exposes param."""
+
+ commands = {
+ Var(
+ "param",
+ read_pattern=r"P\?$",
+ write_pattern=r"P=(\d+)",
+ argument_mappings=(int,),
+ doc="An integer parameter.",
+ )
+ }
+
+ in_terminator = "\r\n"
+ out_terminator = "\r\n"
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from collections import OrderedDict
+
+from lewis.adapters.stream import Cmd, StreamInterface, regex, scanf
+from lewis.core import approaches
+from lewis.core.statemachine import State
+from lewis.devices import StateMachineDevice
+
+
+
+[docs]
+class DefaultMovingState(State):
+
+[docs]
+ def in_state(self, dt) -> None:
+ old_position = self._context.position
+ self._context.position = approaches.linear(
+ old_position, self._context.target, self._context.speed, dt
+ )
+ self.log.info(
+ "Moved position (%s -> %s), target=%s, speed=%s",
+ old_position,
+ self._context.position,
+ self._context.target,
+ self._context.speed,
+ )
+
+
+
+
+
+[docs]
+class SimulatedExampleMotor(StateMachineDevice):
+ def _initialize_data(self) -> None:
+ self.position = 0.0
+ self._target = 0.0
+ self.speed = 2.0
+
+ def _get_state_handlers(self):
+ return {"idle": State(), "moving": DefaultMovingState()}
+
+ def _get_initial_state(self) -> str:
+ return "idle"
+
+ def _get_transition_handlers(self):
+ return OrderedDict(
+ [
+ (("idle", "moving"), lambda: self.position != self.target),
+ (("moving", "idle"), lambda: self.position == self.target),
+ ]
+ )
+
+ @property
+ def state(self):
+ return self._csm.state
+
+ @property
+ def target(self):
+ return self._target
+
+ @target.setter
+ def target(self, new_target) -> None:
+ if self.state == "moving":
+ raise RuntimeError("Can not set new target while moving.")
+
+ if not (0 <= new_target <= 250):
+ raise ValueError("Target is out of range [0, 250]")
+
+ self._target = new_target
+
+
+[docs]
+ def stop(self):
+ """Stops the motor and returns the new target and position, which are equal"""
+
+ self._target = self.position
+
+ self.log.info("Stopping movement after user request.")
+
+ return self.target, self.position
+
+
+
+
+
+[docs]
+class ExampleMotorStreamInterface(StreamInterface):
+ """
+ TCP-stream based example motor interface
+
+ This motor simulation can be controlled via telnet:
+
+ $ telnet host port
+
+ Where the host and port-parameter are part of the dynamically created documentation for
+ a concrete device instance.
+
+ The motor starts moving immediately when a new target position is set. Once it's moving,
+ it has to be stopped to receive a new target, otherwise an error is generated.
+ """
+
+ commands = {
+ Cmd("get_status", regex(r"^S\?$")), # explicit regex
+ Cmd("get_position", r"^P\?$"), # implicit regex
+ Cmd("get_target", r"^T\?$"),
+ Cmd("set_target", scanf("T=%f")), # scanf format specification
+ Cmd("stop", r"^H$", return_mapping=lambda x: "T={},P={}".format(x[0], x[1])),
+ }
+
+ in_terminator = "\r\n"
+ out_terminator = "\r\n"
+
+
+[docs]
+ def get_status(self):
+ """Returns the status of the device, which is one of 'idle' or 'moving'."""
+ return self.device.state
+
+
+
+[docs]
+ def get_position(self):
+ """Returns the current position in mm."""
+ return self.device.position
+
+
+
+[docs]
+ def get_target(self):
+ """Returns the current target in mm."""
+ return self.device.target
+
+
+
+[docs]
+ def set_target(self, new_target):
+ """
+ Sets the new target in mm, the movement starts immediately. If the value is outside
+ the interval [0, 250] or the motor is already moving, an error is returned, otherwise
+ the new target is returned."""
+ try:
+ self.device.target = new_target
+ return "T={}".format(new_target)
+ except RuntimeError:
+ return "err: not idle"
+ except ValueError:
+ return "err: not 0<=T<=250"
+
+
+
+
+setups = dict(
+ moving=dict(
+ device_type=SimulatedExampleMotor,
+ parameters=dict(
+ override_initial_state="moving",
+ override_initial_data=dict(_target=120.0, position=20.0),
+ ),
+ )
+)
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.adapters.modbus import ModbusBasicDataBank, ModbusInterface
+from lewis.devices import Device
+
+
+
+
+
+
+
+[docs]
+class ExampleModbusInterface(ModbusInterface):
+ """
+ The class attributes di, co, ir and hr represent Discrete Inputs, Coils, Input Registers and
+ Holding Registers, respectively. Each attribute should be assigned a ModbusDataBank instance
+ by the Interface implementation.
+
+ Here, two basic ModbusDataBanks are created and initialized to a default value across the full
+ range of valid addresses. One DataBank is shared by di and co, and the other by ir and hr to
+ demonstrate overlaid memory segments. If you want each segment to have its own memory, just
+ create separate instances for all four.
+ """
+
+ di = ModbusBasicDataBank(False)
+ co = di
+ ir = ModbusBasicDataBank(0)
+ hr = ir
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.adapters.stream import Cmd, StreamInterface, Var
+from lewis.devices import Device
+
+
+
+
+
+
+
+[docs]
+class VerySimpleInterface(StreamInterface):
+ """
+ A very simple device with TCP-stream interface
+
+ The device has only one parameter, which can be set to an arbitrary
+ value. The interface consists of five commands which can be invoked via telnet.
+ To connect:
+
+ $ telnet host port
+
+ After that, typing either of the commands and pressing enter sends them to the server.
+
+ The commands are:
+
+ - ``V``: Returns the parameter as part of a verbose message.
+ - ``V=something``: Sets the parameter to ``something``.
+ - ``P``: Returns the device parameter unmodified.
+ - ``P=something``: Exactly the same as ``V=something``.
+ - ``R`` or ``r``: Returns the number 4.
+
+ """
+
+ commands = {
+ Cmd("get_param", pattern="^V$", return_mapping="The value is {}".format),
+ Cmd("set_param", pattern="^V=(.+)$", argument_mappings=(int,)),
+ Var(
+ "param",
+ read_pattern="^P$",
+ write_pattern="^P=(.+)$",
+ doc="The only parameter.",
+ ),
+ Cmd(lambda: 4, pattern="^R$(?i)", doc='"Random" number (4).'),
+ }
+
+ in_terminator = "\r\n"
+ out_terminator = "\r\n"
+
+
+
+
+
+[docs]
+ def set_param(self, new_param) -> None:
+ """Set the device parameter, does not return anything."""
+ self.device.param = new_param
+
+
+
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from lewis.adapters.stream import Cmd, StreamInterface, Var, scanf
+from lewis.devices import Device
+
+
+
+[docs]
+class TimeTerminatedDevice(Device):
+ param = 10
+
+ def say_world(self) -> str:
+ return "world!"
+
+ def say_bar(self) -> str:
+ return "bar!"
+
+
+
+
+[docs]
+class TimeTerminatedInterface(StreamInterface):
+ """
+ A simple device where commands are terminated by a timeout.
+
+ This demonstrates how to implement devices that do not have standard
+ terminators and where a command is considered terminated after a certain
+ time delay of not receiving more data.
+
+ To interact with this device, you must switch telnet into char mode, or use
+ netcat with special tty settings:
+
+ $ telnet host port
+ ^]
+ telnet> mode char
+ [type command and wait]
+
+ $ stty -icanon && nc host port
+ hello world!
+ foobar!
+
+ The following commands are available:
+
+ - ``hello ``: Reply with "world!"
+ - ``foo``: Replay with "bar!"
+ - ``P``: Returns the device parameter
+ - ``P=something``: Set parameter to specified value
+
+ """
+
+ commands = {
+ # Space as \x20 represents a custom 'terminator' for this command only
+ # However, waiting for the timeout still applies
+ Cmd("say_world", pattern=scanf("hello\x20")),
+ Cmd("say_bar", pattern=scanf("foo")),
+ Var("param", read_pattern=scanf("P"), write_pattern=scanf("P=%d")),
+ }
+
+ # An empty in_terminator triggers "timeout mode"
+ # Otherwise, a ReadTimeout is considered an error.
+ in_terminator = ""
+ out_terminator = "\r\n"
+
+ # Unusually long, for easier manual entry.
+ readtimeout = 2500
+
+
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+from io import StringIO
+
+
+
+[docs]
+def get_usage_text(parser, indent=None):
+ """
+ This small helper function extracts the help information from an ArgumentParser instance
+ and indents the text by the number of spaces supplied in the indent-argument.
+
+ :param parser: ArgumentParser object.
+ :param indent: Number of spaces to put before each line or None.
+ :return: Formatted help string of the supplied parser.
+ """
+ usage_text = StringIO()
+ parser.print_help(usage_text)
+
+ usage_string = usage_text.getvalue()
+
+ if indent is None:
+ return usage_string
+
+ return "\n".join([" " * indent + line for line in usage_string.split("\n")])
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+import argparse
+import os
+import sys
+
+import yaml
+
+from lewis import __version__
+from lewis.core.exceptions import LewisException
+from lewis.core.logging import default_log_format, logging
+from lewis.core.simulation import SimulationFactory
+from lewis.scripts import get_usage_text
+
+parser = argparse.ArgumentParser(
+ description="This script starts a simulated device that is exposed via the specified "
+ "communication protocol. Complete documentation of Lewis is available in "
+ "the online documentation on GitHub https://github.com/ess-dmsc/lewis/",
+ add_help=False,
+ prog="lewis",
+)
+
+positional_args = parser.add_argument_group("Positional arguments")
+
+positional_args.add_argument(
+ "device",
+ nargs="?",
+ help="Name of the device to simulate, omitting this argument prints out a list "
+ "of available devices.",
+)
+
+device_args = parser.add_argument_group(
+ "Device related parameters",
+ "Parameters that influence the selected device, such as setup or protocol.",
+)
+
+device_args.add_argument(
+ "-s",
+ "--setup",
+ default=None,
+ help="Name of the setup to load. If not provided, the default setup is selected. If there"
+ "is no default, a list of setups is printed.",
+)
+
+interface_args = device_args.add_mutually_exclusive_group()
+interface_args.add_argument(
+ "-n",
+ "--no-interface",
+ default=False,
+ action="store_true",
+ help="If supplied, the device simulation will not have any communication interface.",
+)
+interface_args.add_argument(
+ "-p",
+ "--adapter-options",
+ default=[],
+ action="append",
+ help="Supply the protocol name and adapter options in the format "
+ '"name:{opt1: val, opt2: val}". Use the -l flag to see which protocols '
+ "are available for the selected device. Can be supplied multiple times for "
+ "multiple protocols.",
+)
+device_args.add_argument(
+ "-l",
+ "--list-protocols",
+ action="store_true",
+ help="List available protocols for selected device.",
+)
+device_args.add_argument(
+ "-L",
+ "--list-adapter-options",
+ action="store_true",
+ help="List available configuration options and their value. Values that have not been "
+ "modified in the -p argument are default values.",
+)
+device_args.add_argument(
+ "-i",
+ "--show-interface",
+ action="store_true",
+ help="Show command interface of device interface.",
+)
+device_args.add_argument(
+ "-k",
+ "--device-package",
+ default="lewis.devices",
+ help="Name of packages where devices are found.",
+)
+device_args.add_argument(
+ "-a",
+ "--add-path",
+ default=None,
+ help="Path where the device package exists. Is added to the path.",
+)
+
+simulation_args = parser.add_argument_group(
+ "Simulation related parameters",
+ "Parameters that influence the simulation itself, such as timing and speed.",
+)
+
+simulation_args.add_argument(
+ "-c",
+ "--cycle-delay",
+ type=float,
+ default=0.1,
+ help="Approximate time to spend in each cycle of the simulation. "
+ "0 for maximum simulation rate.",
+)
+simulation_args.add_argument(
+ "-e",
+ "--speed",
+ type=float,
+ default=1.0,
+ help="Simulation speed. The actually elapsed time between two cycles is "
+ "multiplied with this speed to determine the simulated time.",
+)
+simulation_args.add_argument(
+ "-r",
+ "--rpc-host",
+ default=None,
+ help="HOST:PORT format string for exposing the device and the simulation via "
+ "JSON-RPC over ZMQ. Use lewis-control to access this service from the command line.",
+)
+
+other_args = parser.add_argument_group("Other arguments")
+
+other_args.add_argument(
+ "-o",
+ "--output-level",
+ default="info",
+ choices=["none", "critical", "error", "warning", "info", "debug"],
+ help="Level of detail for logging to stderr.",
+)
+other_args.add_argument(
+ "-V",
+ "--verify",
+ action="store_true",
+ help="Sets the output level to 'debug' and aborts before starting the device simulation. "
+ "This is intended to help with diagnosing problems with devices or input arguments.",
+)
+
+version_handling = other_args.add_mutually_exclusive_group()
+version_handling.add_argument(
+ "-I",
+ "--ignore-versions",
+ action="store_true",
+ help="Ignore version mismatches between device and framework. A warning will still "
+ "be logged.",
+)
+other_args.add_argument(
+ "-v", "--version", action="store_true", help="Prints the version and exits."
+)
+other_args.add_argument("-h", "--help", action="help", help="Shows this help message and exits.")
+
+deprecated_args = parser.add_argument_group("Deprecated arguments")
+deprecated_args.add_argument(
+ "-R",
+ "--relaxed-versions",
+ action="store_true",
+ help="Renamed to --I/--ignore-versions. Using this old option produces an error "
+ "and it will be removed in a future release.",
+)
+
+__doc__ = (
+ "This script is the main interaction point of the user with Lewis. The usage "
+ "is as follows:\n\n.. code-block:: none\n\n{}".format(get_usage_text(parser, indent=4))
+)
+
+
+def parse_adapter_options(raw_adapter_options):
+ if not raw_adapter_options:
+ return {None: {}}
+
+ protocols = {}
+
+ for option_string in raw_adapter_options:
+ try:
+ adapter_options = yaml.safe_load(option_string)
+ except yaml.YAMLError:
+ raise LewisException(
+ "It was not possible to parse this adapter option specification:\n"
+ " %s\n"
+ "Correct formats for the -p argument are:\n"
+ " -p protocol\n"
+ " -p \"protocol: {option: 'val', option2: 34}\"\n"
+ "The spaces after the colons are significant!" % option_string
+ )
+
+ if isinstance(adapter_options, str):
+ protocols[adapter_options] = {}
+ else:
+ protocol = list(adapter_options.keys())[0]
+ protocols[protocol] = adapter_options.get(protocol, {})
+
+ return protocols
+
+
+
+[docs]
+def run_simulation(argument_list=None) -> None: # noqa: C901
+ """
+ This is effectively the main function of a typical simulation run. Arguments passed in are
+ parsed and used to construct and run the simulation.
+
+ This function only exits when the program has completed or is interrupted.
+
+ :param argument_list: Argument list to pass to the argument parser declared in this module.
+ """
+ try:
+ arguments = parser.parse_args(argument_list or sys.argv[1:])
+
+ if arguments.version:
+ print(__version__)
+ return
+
+ if arguments.relaxed_versions:
+ print("Unknown option --relaxed-versions. Did you mean --ignore-versions?")
+ return
+
+ loglevel = "debug" if arguments.verify else arguments.output_level
+ if loglevel != "none":
+ logging.basicConfig(level=getattr(logging, loglevel.upper()), format=default_log_format)
+
+ if arguments.add_path is not None:
+ additional_path = os.path.abspath(arguments.add_path)
+ logging.getLogger().debug("Extending path with: %s", additional_path)
+ sys.path.append(additional_path)
+
+ simulation_factory = SimulationFactory(arguments.device_package)
+
+ if not arguments.device:
+ devices = ["Please specify a device to simulate. The following devices are available:"]
+
+ for dev in sorted(simulation_factory.devices):
+ devices.append(" " + dev)
+
+ print("\n".join(devices))
+ return
+
+ if arguments.list_protocols:
+ print("\n".join(simulation_factory.get_protocols(arguments.device)))
+ return
+
+ protocols = (
+ parse_adapter_options(arguments.adapter_options) if not arguments.no_interface else {}
+ )
+
+ simulation = simulation_factory.create(
+ arguments.device, arguments.setup, protocols, arguments.rpc_host
+ )
+
+ if arguments.show_interface:
+ print(simulation._adapters.documentation())
+ return
+
+ if arguments.list_adapter_options:
+ configurations = simulation._adapters.configuration()
+
+ for protocol, options in configurations.items():
+ print("{}:".format(protocol))
+
+ for opt, val in options.items():
+ print(" {} = {}".format(opt, val))
+
+ return
+
+ simulation.cycle_delay = arguments.cycle_delay
+ simulation.speed = arguments.speed
+
+ if not arguments.verify:
+ try:
+ simulation.start()
+ except KeyboardInterrupt:
+ print("\nInterrupt received; shutting down. Goodbye, cruel world!")
+ simulation.log.critical("Simulation aborted by user interaction")
+ finally:
+ simulation.stop()
+
+ except LewisException as e:
+ print("\n".join(("An error occurred:", str(e))))
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+import struct
+from typing import Literal
+
+BYTE = 2**8
+
+
+def _get_byteorder_name(low_byte_first: bool) -> Literal["little", "big"]:
+ """
+ Get the python name for low byte first
+ :param low_byte_first: True for low byte first; False for MSB first
+ :return: name
+ """
+ return "little" if low_byte_first else "big"
+
+
+
+[docs]
+def int_to_raw_bytes(integer: int, length: int, low_byte_first: bool) -> bytes:
+ """
+ Converts an integer to an unsigned set of bytes with the specified length (represented as a string). Unless the
+ integer is negative in which case it converts to a signed integer.
+
+ If low byte first is True, the least significant byte comes first, otherwise the most significant byte comes first.
+
+ :param integer: The integer to convert.
+ :param length: The length of the result.
+ :param low_byte_first: Whether to put the least significant byte first.
+
+ :return: string representation of the bytes.
+ """
+ return integer.to_bytes(
+ length=length, byteorder=_get_byteorder_name(low_byte_first), signed=integer < 0
+ )
+
+
+
+
+[docs]
+def raw_bytes_to_int(raw_bytes: bytes, low_bytes_first: bool = True) -> int:
+ """
+ Converts an unsigned set of bytes to an integer.
+
+ :param raw_bytes: A string representation of the raw bytes.
+ :param low_bytes_first: Whether the given raw bytes are in little endian or not. True by default.
+
+ :return: The integer represented by the raw bytes passed in.
+ """
+ return int.from_bytes(raw_bytes, byteorder=_get_byteorder_name(low_bytes_first))
+
+
+
+
+[docs]
+def float_to_raw_bytes(real_number: float, low_byte_first: bool = True) -> bytes:
+ """
+ Converts an floating point number to an unsigned set of bytes.
+
+ :param real_number: The float to convert.
+ :param low_byte_first: Whether to put the least significant byte first. True by default.
+
+ :return: A string representation of the bytes.
+ """
+ raw_bytes = bytes(struct.pack(">f", real_number))
+
+ return raw_bytes[::-1] if low_byte_first else raw_bytes
+
+
+
+
+[docs]
+def raw_bytes_to_float(raw_bytes: bytes) -> float:
+ """
+ Convert a set of bytes to a floating point number
+
+ :param raw_bytes: A string representation of the raw bytes.
+
+ :return: float: The floating point number represented by the given bytes.
+ """
+ return struct.unpack("f", raw_bytes[::-1])[0]
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+"""
+A fluent command builder for lewis.
+"""
+
+import re
+from functools import partial
+from typing import AnyStr
+
+from lewis.adapters.stream import Cmd, regex
+from lewis.utils.constants import ACK, ENQ, EOT, ETX, STX
+
+string_arg = partial(str, encoding="utf-8")
+
+
+
+[docs]
+class CmdBuilder(object):
+ """
+ Build a command for the stream adapter.
+
+ Do this by creating this object, adding the values and then building it (this uses a fluent interface).
+
+ For example to read a pressure the ioc might send "pres?" and when that happens this should call get_pres
+ command would be:
+ >>> CmdBuilder("get_pres").escape("pres?").build()
+ This will generate the regex needed by Lewis. The escape is just making sure none of the characters are special
+ reg ex characters.
+ If you wanted to set a pressure the ioc might send "pres <pressure>" where <pressure> is a floating point number,
+ the interface should call set_pres with that number. Now use:
+ >>> CmdBuilder("set_pres").escape("pres ").float().build()
+ this add float as a regularly expression capture group for your argument. It is equivalent to:
+ >>> Cmd("set_pres", "pres ([+-]?\\d+\\.?\\d*)")
+ There are various arguments like int and digit. Finally some special characters are included so if your protocol
+ uses enquirey character ascii 5 you can match is using
+ >>> CmdBuilder("set_pres").escape("pres?").enq().build()
+ """
+
+ def __init__(self, target_method, arg_sep="", ignore="", ignore_case=False) -> None:
+ """
+ Create a builder. Use build to create the final object
+
+ :param target_method: name of the method target to call when the reg ex matches
+ :param arg_sep: separators between arguments which are next to each other
+ :param ignore: set of characters to ignore between text and arguments
+ :param ignore_case: ignore the case when matching command
+ """
+ self._target_method = target_method
+ self._arg_sep = arg_sep
+ self._current_sep = ""
+ self.argument_mappings = []
+ if ignore is None or ignore == "":
+ self._ignore = ""
+ else:
+ self._ignore = "[{0}]*".format(ignore)
+ self._reg_ex = self._ignore
+
+ self._ignore_case = ignore_case
+
+ def _add_to_regex(self, regex, is_arg: bool) -> None:
+ self._reg_ex += regex + self._ignore
+ if not is_arg:
+ self._current_sep = ""
+
+
+[docs]
+ def optional(self, text) -> "CmdBuilder":
+ """
+ Add some escaped text which does not necessarily need to be there. For commands with optional parameters
+ :param text: Text to add
+ :return: builder
+ """
+ self._add_to_regex("(?:" + re.escape(text) + ")?", False)
+ return self
+
+
+
+[docs]
+ def escape(self, text) -> "CmdBuilder":
+ """
+ Add some text to the regex which is escaped.
+
+ :param text: text to add
+ :return: builder
+ """
+ self._add_to_regex(re.escape(text), False)
+ return self
+
+
+
+[docs]
+ def regex(self, new_regex: str) -> "CmdBuilder":
+ """
+ Add a regex to match but not as an argument.
+
+ :param new_regex: regex to add
+ :return: builder
+ """
+ self._add_to_regex(new_regex, False)
+ return self
+
+
+
+[docs]
+ def enum(self, *allowed_values: AnyStr) -> "CmdBuilder":
+ """
+ Matches one of a set of specified strings.
+
+ :param allowed_values: the values this function is allowed to match
+ :return: builder
+ """
+ self._add_to_regex(
+ "({})".format("|".join([re.escape(arg) for arg in allowed_values])),
+ is_arg=True,
+ )
+ self.argument_mappings.append(string_arg)
+ return self
+
+
+
+[docs]
+ def spaces(self, at_least_one: bool = False) -> "CmdBuilder":
+ """
+ Add a regex for any number of spaces
+
+ :param at_least_one: true there must be at least one space; false there can be any number including zero
+ :return: builder
+
+ """
+ wildcard = "+" if at_least_one else "*"
+
+ self._add_to_regex(" " + wildcard, False)
+ return self
+
+
+
+[docs]
+ def arg(self, arg_regex, argument_mapping: partial = string_arg) -> "CmdBuilder":
+ """
+ Add an argument to the command.
+
+ :param arg_regex: regex for the argument (capture group will be added)
+ :param argument_mapping: the type mapping for the argument (default is str)
+ :return: builder
+ """
+ self._add_to_regex(self._current_sep + "(" + arg_regex + ")", True)
+ self._current_sep = self._arg_sep
+ self.argument_mappings.append(argument_mapping)
+ return self
+
+
+
+[docs]
+ def string(self, length: None | int = None) -> "CmdBuilder":
+ """
+ Add an argument which is a string of a given length (if blank string is any length)
+
+ :param length: length of string; None for any length
+ :return: builder
+ """
+ if length is None:
+ self.arg(".+")
+ else:
+ self.arg(".{{{}}}".format(length))
+ return self
+
+
+
+[docs]
+ def float(self, mapping: type = float, ignore: bool = False) -> "CmdBuilder":
+ """
+ Add a float argument.
+
+ :param mapping: The type to cast the response to (default: float)
+ :param ignore: True to match with a float but ignore the returned value (default: False)
+ :return: builder
+ """
+ regex = r"[+-]?\d+\.?\d*"
+ return self.regex(regex) if ignore else self.arg(regex, mapping)
+
+
+
+[docs]
+ def digit(self, mapping: type = int, ignore: bool = False) -> "CmdBuilder":
+ """
+ Add a single digit argument.
+
+ :param mapping: The type to cast the response to (default: int)
+ :param ignore: True to match with a digit but ignore the returned value (default: False)
+ :return: builder
+ """
+ return self.regex(r"\d") if ignore else self.arg(r"\d", mapping)
+
+
+
+[docs]
+ def char(self, not_chars: None | list[str] | str = None, ignore=False) -> "CmdBuilder":
+ """
+ Add a single character argument.
+
+ :param not_chars: characters that the character can not be; None for can be anything
+ :param ignore: True to match with a char but ignore the returned value (default: False)
+ :return: builder
+ """
+ _regex = r"." if not_chars is None else "[^{}]".format("".join(not_chars))
+ return self.regex(_regex) if ignore else self.arg(_regex)
+
+
+
+[docs]
+ def int(self, mapping: type = int, ignore: bool = False) -> "CmdBuilder":
+ """
+ Add an integer argument.
+
+ :param mapping: The type to cast the response to (default: int)
+ :param ignore: True to match with a int but ignore the returned value (default: False)
+ :return: builder
+ """
+ _regex = r"[+-]?\d+"
+ return self.regex(_regex) if ignore else self.arg(_regex, mapping)
+
+
+
+[docs]
+ def any(self) -> "CmdBuilder":
+ """
+ Add an argument that matches anything.
+
+ :return: builder
+ """
+ return self.arg(r".*")
+
+
+
+[docs]
+ def any_except(self, char: str) -> "CmdBuilder":
+ """
+ Adds an argument that matches anything other than a specified character (useful for commands containing
+ delimiters)
+
+ :param char: the character not to match
+ :return: builder
+ """
+ return self.arg(r"[^{}]*".format(re.escape(char)))
+
+
+
+[docs]
+ def build(self, *args, **kwargs) -> Cmd:
+ """
+ Builds the CMd object based on the target and regular expression.
+
+ :param args: arguments to pass to Cmd constructor
+ :param kwargs: key word arguments to pass to Cmd constructor
+ :return: Cmd object
+ """
+ if self._ignore_case:
+ pattern = regex(self._reg_ex)
+ pattern.compiled_pattern = re.compile(self._reg_ex.encode(), re.IGNORECASE)
+ else:
+ pattern = self._reg_ex
+ return Cmd(
+ self._target_method,
+ pattern,
+ argument_mappings=self.argument_mappings,
+ *args,
+ **kwargs,
+ )
+
+
+
+[docs]
+ def add_ascii_character(self, char_number: int) -> "CmdBuilder":
+ """
+ Add a single character based on its integer value, e.g. 49 is 'a'.
+
+ :param char_number: character number
+ :return: self
+ """
+ self._add_to_regex(chr(char_number), False)
+ return self
+
+
+
+[docs]
+ def stx(self) -> "CmdBuilder":
+ """
+ Add the STX character (0x2) to the string.
+
+ :return: builder
+ """
+ return self.escape(STX)
+
+
+
+[docs]
+ def etx(self) -> "CmdBuilder":
+ """
+ Add the ETX character (0x3) to the string.
+
+ :return: builder
+ """
+ return self.escape(ETX)
+
+
+
+[docs]
+ def eot(self) -> "CmdBuilder":
+ """
+ Add the EOT character (0x4) to the string.
+
+ :return: builder
+ """
+ return self.escape(EOT)
+
+
+
+[docs]
+ def enq(self) -> "CmdBuilder":
+ """
+ Add the ENQ character (0x5) to the string.
+
+ :return: builder
+ """
+ return self.escape(ENQ)
+
+
+
+[docs]
+ def ack(self) -> "CmdBuilder":
+ """
+ Add the ACK character (0x6) to the string.
+
+ :return: builder
+ """
+ return self.escape(ACK)
+
+
+
+[docs]
+ def eos(self) -> "CmdBuilder":
+ """
+ Adds the regex end-of-string character to a command.
+
+ :return: builder
+ """
+ self._reg_ex += "$"
+ return self
+
+
+
+[docs]
+ def get_multicommands(self, command_separator: AnyStr) -> "CmdBuilder":
+ """
+ Allows emulator to split multiple commands separated by a defined command separator, e.g. ";".
+ Must be accompanied by stream device methods. See Keithley 2700 for examples
+
+ :param command_separator: Character(s) that separate commands
+ :return: builder
+ """
+ self.arg("[^" + re.escape(command_separator) + "]*").escape(command_separator).arg(".*")
+ return self
+
+
+
+# -*- coding: utf-8 -*-
+# *********************************************************************
+# lewis - a library for creating hardware device simulators
+# Copyright (C) 2016-2021 European Spallation Source ERIC
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+# *********************************************************************
+
+import time
+from functools import wraps
+from typing import Callable, ParamSpec, TypeVar
+
+from lewis.adapters.stream import StreamInterface
+from lewis.core.logging import has_log
+
+T = TypeVar("T")
+P = ParamSpec("P")
+
+
+def _get_device_from(instance: StreamInterface):
+ try:
+ device = instance.device
+ except AttributeError:
+ try:
+ device = instance._device
+ except AttributeError:
+ raise AttributeError(
+ "Expected device to be accessible as either self.device or self._device"
+ )
+ return device
+
+
+
+[docs]
+def conditional_reply(property_name: str, reply: str | None = None) -> Callable[P, T]:
+ """
+ Decorator that executes the command and replies if the device has a member called
+ 'property name' and it is True in a boolean context.
+
+ Example usage:
+
+ .. sourcecode:: Python
+
+ @conditional_reply("connected")
+ def acknowledge_pressure(channel):
+ return ACK
+
+ :param property_name: The name of the property to look for on the device
+ :param reply: Desired output reply string when condition is false
+
+ :return: The function returns as normal if property is true.
+ The command is not executed and there is no reply if property is false
+
+ :except AttributeError if the first argument of the decorated function (self)
+ does not contain .device or ._device
+ :except AttributeError if the device does not contain a property called property_name
+ """
+
+ def decorator(func: Callable[P, T]) -> Callable[P, T]:
+ @wraps(func)
+ def wrapper(self: StreamInterface, *args: P.args, **kwargs: P.kwargs) -> T:
+ device = _get_device_from(self)
+
+ try:
+ do_reply = getattr(device, property_name)
+ except AttributeError:
+ raise AttributeError(
+ f"Expected device to contain an attribute called '{property_name}' "
+ f"but it wasn't found."
+ )
+
+ return func(self, *args, **kwargs) if do_reply else reply
+
+ return wrapper
+
+ return decorator
+
+
+
+class _LastInput:
+ last_input_time = 0
+
+
+
+[docs]
+@has_log
+def timed_reply(
+ action: str, reply: str | None = None, minimum_time_delay: float = 0
+) -> Callable[P, T]:
+ """
+ Decorator that inhibits a command and performs an action if call time is less than
+ some minimum time delay between the current and last input.
+
+ Example usage:
+
+ .. sourcecode:: Python
+
+ @timed_reply(action="crash_pump", reply="WARNING: Input too quick", minimum_time_delay=150)
+ def acknowledge_pressure(channel):
+ return ACK
+
+ :param action: The name of the method to execute for on the device
+ :param reply: Desired output reply string when input time delay is less than the minimum
+ :param minimum_time_delay: The minimum time (ms) between commands sent to the device
+
+ :return: The function returns as normal if minimum delay exceeded.
+ The command is not executed and the action method is called on the device instead
+
+ :except AttributeError if the first argument of the decorated function (self)
+ does not contain .device or ._device
+
+ :except AttributeError if the device does not contain a property called action
+ """
+
+ def decorator(func: Callable[P, T]) -> Callable[P, T]:
+ @wraps(func)
+ def wrapper(self: StreamInterface, *args: P.args, **kwargs: P.kwargs) -> T:
+ try:
+ new_input_time = int(round(time.time() * 1000))
+ time_since_last_request = new_input_time - _LastInput.last_input_time
+ valid_input = time_since_last_request > minimum_time_delay
+ if valid_input:
+ _LastInput.last_input_time = new_input_time
+ return func(self, *args, **kwargs)
+ else:
+ self.log.info(
+ f"Violated time tolerance ({minimum_time_delay}ms) was"
+ f" {time_since_last_request}ms."
+ f" Calling action ({action}) on device"
+ )
+ device = _get_device_from(self)
+ action_function = getattr(device, action)
+ action_function()
+ return reply
+
+ except AttributeError:
+ raise AttributeError(
+ f"Expected device to contain an attribute called '{self.action}' but it"
+ f" wasn't found."
+ )
+
+ return wrapper
+
+ return decorator
+
+
' + + '' + + _("Hide Search Matches") + + "
" + ) + ); + }, + + /** + * helper function to hide the search marks again + */ + hideSearchWords: () => { + document + .querySelectorAll("#searchbox .highlight-link") + .forEach((el) => el.remove()); + document + .querySelectorAll("span.highlighted") + .forEach((el) => el.classList.remove("highlighted")); + localStorage.removeItem("sphinx_highlight_terms") + }, + + initEscapeListener: () => { + // only install a listener if it is really needed + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; + // bail with special keys + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return; + if (DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS && (event.key === "Escape")) { + SphinxHighlight.hideSearchWords(); + event.preventDefault(); + } + }); + }, +}; + +_ready(() => { + /* Do not call highlightSearchWords() when we are on the search page. + * It will highlight words from the *previous* search query. + */ + if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); + SphinxHighlight.initEscapeListener(); +}); diff --git a/developer_guide/developing_lewis.html b/developer_guide/developing_lewis.html new file mode 100644 index 00000000..fedd462e --- /dev/null +++ b/developer_guide/developing_lewis.html @@ -0,0 +1,202 @@ + + + + + + + + +Begin by checking-out the source from GitHub:
+(lewis-dev)$ git clone https://github.com/ISISComputingGroup/lewis.git
+
To develop Lewis, it is strongly recommended to work in a dedicated virtual environment, otherwise +it is not possible to have another version of Lewis installed system wide in parallel.
+With the virtual environment activated, Lewis can be installed as an editable package:
+(lewis-dev)$ cd lewis
+(lewis-dev)$ python -m pip install ".[dev]"
+
Now the Lewis package that resides in lewis
can be modified, while it is still treated like a
+normal package that has been installed via pip
.
Alternatively, Lewis can be run from source. For this it is necessary to install the requirements first:
+(lewis-dev)$ cd lewis
+(lewis-dev)$ python -m pip install -r requirements-dev.txt
+
Either way, to make sure that everything is working as +intended, run the unit tests and check for pep8 errors, as well as build the documentation:
+(lewis-dev)$ pytest tests
+(lewis-dev)$ flake8 setup.py lewis scripts system-tests tests
+(lewis-dev)$ sphinx-build -W -b html docs/ docs/_build/html
+
There are also system-tests that (partially) test Lewis from the application/run-time level. These tests are based on
+the Approval Tests Framework <https://approvaltests.com/>
__ which works by comparing a program’s standard output
+against a “golden master” - if the output doesn’t match then the tests fail.
+For lewis
and lewis-control
the tests check that the programs work together correctly. For example: if a value
+on a simulated device in lewis
is changed via ``lewis-control``` then by querying the status of the device the
+values can be compared against the expected status (the “golden master”). The tests can be run like so:
(lewis-dev)$ pytest system_tests/lewis_tests.py
+
It is good practice to run these tests regularly during development and, also, look for opportunities to add +more tests. The tests will also be run via the CI system.
+A more comprehensive way of running all tests is to use tox
, which creates fresh virtual
+environments for all of these tasks:
(lewis-dev)$ tox
+
The advantage of tox is that it generates a source package from the source tree and installs +it in the virtual environments that it creates, testing closer to the thing that is actually +installed in the end. Running all the verification steps this way takes a bit longer, so during +development it might be more desirable to just run the components that are necessary.
+Before starting development it is important to install the pre-commit hooks, so that formatting and flake8
checks
+are performed before code is committed:
(lewis-dev)$ pre-commit install
+
To test that the hooks are installed correctly and to run them manually use the following command:
+(lewis-dev)$ pre-commit run --all-files
+
Development should happen in a separate branch. If the work is related to a specific issue, +it is good practice to include the issue number in the branch name, along with a short +summary of a few words, for example:
+(lewis-dev)$ git checkout -b 123_enhance_logic_flow
+
It’s also good practice to push the branch back to github from time to time, so that other +members of the development team can see what’s going on (even before a pull request is opened):
+(lewis-dev)$ git push origin 123_enhance_logic_flow
+
During development it is good practice to regularly test that changes do not break existing +or new tests. Before opening a pull request on github (which will run all the tests again +under different Python environments via the CI system), it is recommended to run tox one last time +locally, as that resembles the conditions in the CI environment quite closely.
+The Lewis framework is built around a cycle-driven core which in turn drives +the device simulation, including an optional StateMachine, and shared protocol +adapters that separate the communication layer from the simulated device.
+All processing in the framework occurs during “heartbeat” simulation ticks
+which propagate calls to process
methods throughout the simulation,
+along with a Δt parameter that contains the time that has
+passed since the last tick. The device simulation is then responsible for
+updating its state based on how much time has passed and what input has
+been received during that time.
The benefits of this approach include:
+This closely models real device behaviour, since processing in +electronic devices naturally occurs on a cycle basis.
As a side-effect of the above, certain quirks of real devices are +often captured by the simulated device naturally, without additional +effort.
The simulation becomes deterministic: The same amount of process +cycles, with the same Δt parameters along the way, and +the same input via the device protocol, will always result in exactly +the same device state.
Simulation speed can be controlled by increasing (fast-forward) or +decreasing (slow-motion) the Δt parameter by a given factor.
Simulation fidelity can be controlled independently from speed by +increasing or decreasing the number of cycles per second while +adjusting the Δt parameter to compensate.
The above traits are very desirable both for running automated tests +against the simulation, and for debugging any issues that are +identified.
+A class designed for a cycle-driven approach is provided to allow modeling complex +device behaviour in an event-driven fashion.
+A device may initialize a statemachine on construction, telling it what +states the device can be in and what conditions should cause it to +transition between them. The statemachine will automatically check +eligible (exiting current state) transition conditions every cycle and +perform transitions as necessary, triggering callbacks for any event +that occurs. The following events are available for every state:
+on_exit
is triggered once just before exiting the state
on_entry
is triggered once when entering the state
in_state
is triggered every cycle that ends in the state
Every cycle will trigger exactly one in_state
event. This will
+always be the last event of the cycle. When no transition occurs, this
+is the only event. On the very first cycle of a simulation run,
+on_entry
is raised against the initial state before raising an
+in_state
against it. Any other cycles that involve a transition
+first raise on_exit
against the current state, and then raise
+on_entry
and in_state
against the new state. Only one transition
+may occur per cycle.
There are three ways to specify event handlers when initializing the +statemachine:
+Object-Oriented: Implement one class per state, derived from
+lewis.core.statemachine.State
, which optionally contains up to
+one of each event handler
Function-Driven: Bind individual functions to individual events that +need handling
Implicit: Implement handlers in the device class, with standard names
+like on_entry_init
for a state called “init”, and call
+bindHandlersByName()
This document provides a check list of steps to take and things to watch out +for when preparing a new release of Lewis. It is organized roughly in the order +that these things need to be done or checked.
+If any issues are found, it is best to start again at the top once they are +resolved and the fix is merged.
+These steps are to prepare for a release on Git, and to commit as a pull +request named “Prepare release x.y.z”. This pull request should be merged +prior to proceeding to the next section.
+Go to https://github.com/ISISComputingGroup/lewis/milestones
Ensure all issues and PRs included in this release are tagged correctly
Create milestone for next release
Ensure any open issues or PRs not included are tagged for next release
Ensure release notes are up to date against all included changes
If changes to existing devices may be required to update Lewis, include an +Update Guide section in the release notes
Include new release notes in docs/release_notes/
Remove orphan tag from release notes for this release
Update __version__
in lewis/__init__.py
Update release
in docs/conf.py
Update version
in setup.py
Draft release blurb at https://github.com/ISISComputingGroup/lewis/releases
Merge any changes made in this section into the main branch
Ensure this pull request is also tagged for the current version
These steps should be taken once the ones in the previous section have been +completed.
+This should be done in a clean directory.
+$ python -m venv build
+$ . build/bin/activate
+(build) $ git clone https://github.com/ISISComputingGroup/lewis.git
+(build) $ cd lewis
+(build) $ pip install twine wheel
+(build) $ python setup.py sdist bdist_wheel
+(build) $ twine check dist/*
+(build) $ deactivate
+
Ideally, .tar.gz
and .whl
produced in previous step should also be shared
+with and tested by another developer.
Make sure tests are run in a fresh virtual environment:
+$ python -m venv targz
+$ . targz/bin/activate
+(targz) $ pip install lewis/dist/lewis-X.Y.Z.tar.gz
+(targz) $ lewis linkam_t95
+...
+(targz) $ deactivate
+
+$ python -m venv whl
+$ . whl/bin/activate
+(whl) $ pip install lewis/dist/lewis-X.Y.Z-py3-none-any.whl
+(whl) $ lewis linkam_t95
+...
+(whl) $ deactivate
+
Since these are release packages, unit tests aren’t available. Run a few manual +tests against the packaged version of Lewis to double check that things still +work as expected.
+Finalize and submit release blurb at: +https://github.com/ISISComputingGroup/lewis/releases
Close the current milestone at: +https://github.com/ISISComputingGroup/lewis/milestones
The twine
utility can be used to upload the packages to PyPI:
$ twine upload dist/*
+
Note: requires a PyPi account and lewis permissions.
+The following section describes how to write a new device simulator, what to +consider, and how to get the changes upstream.
+The Lewis framework provides all the infrastructure to run device +simulations so that developing a new simulation requires little more +than writing code for the actual device.
+The process of writing a new device simulator is best explained using +the example of a stateful device.
+All that is required to develop a new device is to install Lewis, preferably +in a fresh virtual environment:
+$ pip install lewis
+
The hypothetical device that is to be simulated is a simple controller
+that controls one motor and can be communicated with via a
+TCP <https://en.wikipedia.org/wiki/Transmission_Control_Protocol>
__
+connection. The user can connect to the device using telnet and submit
+commands followed by \r\n
(automatically added by
+telnet <https://linux.die.net/man/1/telnet>
__). Responses are followed
+by \r\n
as well. The following commands and responses are available:
S?
: Returns the status of the motor connected to the controller.
+Can be either idle
or moving
, is initially idle
.
P?
: Returns the current position of the motor in mm. Is initially 0.
T=10.0
: Sets the target position to 10.0
(accepts any
+floating point number) and starts a movement if the position is
+within the limits [0, 250] and returns T=10.0
. If the motor is
+not in idle state, it returns err: not idle
. If the value
+violates the limits, it returns err: not 0<=T<=250
.
T?
Returns the current target of the motor in mm. Is initially 0.
H
: Stops the movement by setting the target to the current
+position and returns T=6.555,P=6.555
. If the motor is idle,
+nothing happens, but the values are returned anyway.
In the simplest approach, the parameters that can describe the device +are:
+position: Read only.
target: Can be read and written by the user, but with certain +restrictions.
Additionally, the device is stateful in the sense that it can be in one +of three states.
+idle
: The motor is powered on and ready to receive commands.
moving
: The motor is moving towards the user supplied target.
Between those three states, different transitions exist:
+idle
-> moving
: The target position is different from the
+current position, the motor starts moving.
moving
-> idle
: The motor has reached the target position or
+the user has supplied a stop command, which sets the target position
+to the current position, causing the motor to stop.
The states and transitions described above form a finite state machine +with two states and two transitions. This state machine forms the heart +of the simulated device, so it should be implemented using Lewis’ +cycle based finite state +machine, which +will be explained below.
+In many cases there may eventually be more than one device simulation, so the directory
+structure should be something like this, assuming your directory is /some/path
:
/some/path
+ |
+ +- my_devices
+ |
+ +- device_1
+ |
+ +- device_2
+ |
+ +- __init__.py (Empty file)
+
Each device resides in the sub-package my_devices
in the. The first step is to create a
+new directory in the my_devices
directory called example_motor
,
+which should contain a single file, __init__.py
. For simple devices
+like this it’s acceptable to put everything into one file, but for more
+complex simulators it’s recommended to follow the structure of the
+devices that are already part of the Lewis distribution.
Conceptually, in Lewis, devices are split in two Parts: a device +model, which contains internal device state, as well as potentially a +state machine, and an interface that exposes the device to the outside +world via a communication protocol that is provided by an “adapter”. The +adapter specifies the communication protocol (for example +EPICS or TCP/IP), whereas the +interface specifies the syntax and semantics of the actual command +language of the device.
+For the actual device simulation there are two classes to choose between
+for sub-classing. The class lewis.devices.Device
can be used for very simple
+devices that do not require a state machine to represent their
+operation. On each simulation cycle, the method doProcess
is
+executed if it is implemented. This can be used to implement
+time-dependent behavior. For the majority of cases, such as in the
+example, it is more convenient to inherit from lewis.devices.StateMachineDevice
.
+It provides an internal state machine and options to override
+characteristics of the state machine on initialization.
lewis.devices.StateMachineDevice
has three methods that must be implemented by
+sub-classes: lewis.devices.StateMachineDevice._get_state_handlers
,
+lewis.devices.StateMachineDevice._get_initial_state
and
+lewis.devices.StateMachineDevice._get_transition_handlers
. They are used to define
+the state machine. A fourth, optional method can be used to initialize internal device
+state, it’s calld lewis.devices.StateMachineDevice._initialize_data
. In this case
+the device implementation should also go into __init__.py
:
from lewis.devices import StateMachineDevice
+
+from lewis.core.statemachine import State
+from lewis.core import approaches
+
+from collections import OrderedDict
+
+class DefaultMovingState(State):
+ def in_state(self, dt):
+ old_position = self._context.position
+ self._context.position = approaches.linear(old_position, self._context.target,
+ self._context.speed, dt)
+ self.log.info('Moved position (%s -> %s), target=%s, speed=%s', old_position,
+ self._context.position, self._context.target, self._context.speed)
+
+class SimulatedExampleMotor(StateMachineDevice):
+ def _initialize_data(self):
+ self.position = 0.0
+ self._target = 0.0
+ self.speed = 2.0
+
+ def _get_state_handlers(self):
+ return {
+ 'idle': State(),
+ 'moving': DefaultMovingState()
+ }
+
+ def _get_initial_state(self):
+ return 'idle'
+
+ def _get_transition_handlers(self):
+ return OrderedDict([
+ (('idle', 'moving'), lambda: self.position != self.target),
+ (('moving', 'idle'), lambda: self.position == self.target)])
+
+ @property
+ def state(self):
+ return self._csm.state
+
+ @property
+ def target(self):
+ return self._target
+
+ @target.setter
+ def target(self, new_target):
+ if self.state == 'moving':
+ raise RuntimeError('Can not set new target while moving.')
+
+ if not (0 <= new_target <= 250):
+ raise ValueError('Target is out of range [0, 250]')
+
+ self._target = new_target
+
+ def stop(self):
+ self._target = self.position
+
+ self.log.info('Stopping movement after user request.')
+
+ return self.target, self.position
+
This defines the state machine according to the description at the top
+of the page and some internal state variables, for example target
,
+which has some limits on when and to what values it can be set.
Both states of the motor are described by a state handler. In case of
+the idle
-state it is enough to use lewis.core.statemachine.State
,
+which simply does nothing. lewis.core.statemachine.State
has three methods that
+can be overridden:
lewis.core.statemachine.State.on_entry
lewis.core.statemachine.State.in_state
lewis.core.statemachine.State.on_exit
.
For other ways to specify those state handlers, please consult the documentation of
+lewis.core.statemachine.StateMachine
, where this is described in detail.
+The advantage of using the lewis.core.statemachine.State
-class is that it
+has a so called context, which is stored in the _context
-member. In case of
+lewis.devices.StateMachineDevice
, this context is the device object.
+This means that device data can be modified in a state handler.
This is the case for the moving
-state, where a state handler has
+been defined by sub-classing lewis.core.statemachine.State
.
+In its in_state
-method it modifies the position
member of the device until it has reached
+target
with a rate that is stored in the speed
-member. This
+linear change behavior is implemented in the ~lewis.core.approaches.linear
-function from
+lewis.core.approaches
. It automatically makes sure that the target is
+always obtained even for very coarse dt
-values.
The transitions between states are defined using lambda-functions in +this case, which simply check whether the current position is identical +with the target or not.
+The device also provides a read-only property state
, which forwards
+the state machine’s (in the device as member _csm
) state. The speed
+of the motor is not part of the device specification, but it is added as
+a member so that it can be changed via the lewis-control
script to test
+how the motor behaves at different speeds. The device is now fully
+functional, but it’s not possible to interact with it yet, because the
+interface is not specified yet.
Device interfaces are implemented by sub-classing an appropriate
+pre-written, protocol specific interface base class from the framework’s
+lewis.adapters
-package and overriding a few members. In this case this
+base class is called lewis.adapters.stream.StreamInterface
. The first step
+is to specify the available commands in terms of a collection of
+lewis.adapters.stream.Cmd
-objects. These objects effectively bind
+commands specified in terms of regular expressions to the interface’s methods.
+According to the specifications above, the commands are defined like this:
from lewis.adapters.stream import StreamInterface, Cmd, scanf
+
+class ExampleMotorStreamInterface(StreamInterface):
+ commands = {
+ Cmd('get_status', r'^S\?$'),
+ Cmd('get_position', r'^P\?$'),
+ Cmd('get_target', r'^T\?$'),
+ Cmd('set_target', scanf('T=%f'), argument_mappings=(float,)),
+ Cmd('stop', r'^H$',
+ return_mapping=lambda x: 'T={},P={}'.format(x[0], x[1])),
+ }
+
+ in_terminator = '\r\n'
+ out_terminator = '\r\n'
+
+ def get_status(self):
+ return self.device.state
+
+ def get_position(self):
+ return self.device.position
+
+ def get_target(self):
+ return self.device.target
+
+ def set_target(self, new_target):
+ try:
+ self.device.target = new_target
+ return 'T={}'.format(new_target)
+ except RuntimeError:
+ return 'err: not idle'
+ except ValueError:
+ return 'err: not 0<=T<=250'
+
The first argument to lewis.adapters.stream.Cmd
specifies the method
+name the command is bound to, whereas the second argument is a pattern that a
+request coming in over the TCP stream must match. If the pattern is specified as a string,
+it is treated as a regular expression. In the above example, lewis.adapters.stream.scanf
+is used for one of the functions, it allows for scanf
-like format specifiers. If a method has
+arguments (such as set_target
), these need to be defined as capture
+groups in the regular expression. These groups are passed as strings to
+the bound method. If any sort of conversion is required for these
+arguments, the argument_mapping
-parameter can be a tuple of
+conversion functions with the same lengths as the number of capture
+groups in the regular expression. In the case of set_target
it’s
+enough to convert the string to float, but lewis.adapters.stream.scanf
does that
+automatically, so it is not strictly required here. Return values (except None
)
+are converted to strings automatically, but this conversion can be
+overridden by supplying a callable object to return_mapping
, as it
+is the case for the stop
-command.
You may have noticed that stop
is not a method of the interface.
+lewis.adapters.stream.StreamInterface
tries to resolve the supplied method
+names in multiple ways. First it checks its own members, then it checks the members of the
+device it owns (accessible in the interface via the device
-member)
+and binds to the appropriate method. If the method name can not be
+found in either the device or the interface, an error is produced, which
+minimizes the likelihood of typos. The definitions in the interface
+always have precedence, this is intentionally done so that device
+behavior can be overridden later on with minimal changes to the code.
In case of the stop
-method, which returns two floating point numbers
+(target and position), the return_mapping
is used to format the
+device’s position and target as specified in the protocol definition at
+the top of the page.
Finally, in- and out-terminators need to be specified. These are +stripped from and appended to requests and replies respectively.
+This entire device can also be found in the lewis.examples
module. It can be
+started using the -a
and -k
parameters of lewis.py
:
$ lewis -a /some/path -k my_devices example_motor -p "stream: {bind_address: 127.0.0.1, port: 9999}"
+
All functionality described in the
+user_guide
, such as accessing the device and the simulation via the
+lewis-control.py
-script are automatically available.
Both device and interface support logging, they supply a log
member which is
+a logger configured with the right name. The adapter already logs all important actions
+that influence the device, so in the interface it should not be necessary to do too much
+logging, but it might be interesting for debugging purposes.
Note that the simulation already produces one debug log message per simulation cycle logging
+the elapsed (real-)time, so it is not necessary to log the dt
parameters in addition.
+lewis.core.statemachine.StateMachine
also logs on each cycle which state it is in and
+which transitions are triggered (if any). In the lewis.core.statemachine.State
-handlers
+that are device specific, any logging should focus on the behavior in that concrete state, as
+for example demonstrated in the example above.
It is also important to consider the log level. Log messages that occur on each cycle must be
+strictly limited to the debug
-level, because they potentially produce a lot of data.
+The info
-level and above should be used for information that is relevant to anyone running
+the simulation, such as failures or other “virtual problems” that might otherwise go unnoticed.
+A good example would be a device that ignores faulty commands - a warning
could be logged
+with details about the command and that it was ignored.
The lewis.adapters.stream.StreamAdapter
-class has a property
+documentation
, which generates user facing documentation from the
+lewis.adapters.stream.Cmd
-objects (it can be displayed via the -i
-flag of
+lewis
from the interface
object via lewis-control.py
). The regular expression of
+each command is listed, along with a documentation string. If the doc
-parameter is provided
+to Cmd, it is used,otherwise the docstring of the wrapped method is used (it does not matter
+whether the method is part of the device or the interface for feature to work). The latter is the
+recommended way, because it avoids duplication. But in some cases, the user- and the
+developer facing documentation may be so different that it’s useful to override the docstring.
This is also combined with the docstring of the interface (in this case
+ExampleMotorStreamInterface
), and some information about the configured host/port,
+as well as terminators. The documentation has been left out from the above code samples for
+brevity, but in the examples
-directory, the docs are present.
All adapters offer similar functionality, the purpose is that the devices are documented in +a way that makes them easy to use by non-developers. This is especially important if the +protocol is non-obvious.
+Unit tests should be added to the test
-directory. While it would be
+best to have unit tests for device and interface separately, it is most
+important that the tests capture overall device behavior, so that it’s
+immediately noticed when a change to Lewis’ core parts breaks the
+simulation. It also makes it easier later on to refactor and change the
+device.
In order to test certain failure scenarios of a device, setups can be
+added to a device. The easiest way is to define a dictionary called
+setups
in the __init__.py
file. A setup consists of a device
+type and initialization parameters:
setups = dict(
+ moving=dict(
+ device_type=SimulatedExampleMotor,
+ parameters=dict(
+ override_initial_state='moving',
+ override_initial_data=dict(
+ _target=120.0, position=20.0
+ )
+ )
+ )
+)
+
In this case a moving
-scenario is defined where the motor is already
+moving to a target when the simulation is started.
To make sure that users have a good experience using the newly added device, +it should specify what version of Lewis it works with. This is achieved by +adding another variable to the top level of the device module which contains +a version specification:
+framework_version = '1.0.1'
+
This will make sure that older or newer versions of Lewis do not present odd exceptions +or error messages to users trying to start the device. If Lewis detects a mismatch +between the required version and the existing version, an error message is logged +so that users know where the problem comes from. In the ideal case this variable +would be updated with each release of Lewis after it has been made sure that the +device is compatible.
+Once a device is developed far enough, it’s time to submit a pull +request. As an external contributor, this happens via a fork on github. +Members of the development team will review the code and may make +suggestions for changes. Once the code is acceptable, it will be merged +into Lewis’ main branch and become a part of the distribution.
+If a second interface is added to a device, either using a different
+interface type or the same but with different commands, the interface
+definitions should be moved out of the __init__.py
file. Lewis
+will continue to work if the interfaces are moved to a sub-folder of the
+device called interfaces
. This needs to have its own
+__init__.py
, where interface-classes can be imported from other
+files in that module. It’s best to look at the chopper and linkam_t95
+devices that are already in Lewis.
The same is true for setups. For complex setups, these should be moved
+to a sub-module of the device called setups
, where each setup can
+live in its own file. Please see the documentation of
+lewis.devices.import_device
for reference.
More example devices and interfaces are provided in the lewis.examples
directory.
lewis.adapters.epics
Members
++ | Class to represent PVs that are bound to an adapter |
+
+ | This adapter provides ChannelAccess server functionality through the pcaspy module. |
+
+ | Inheriting from this class provides an EPICS-interface to a device for use with |
+
+ | The PV-class is used to declare the EPICS-interface exposed by a sub-class of EpicsAdapter. |
+
|
++ |
Bases: object
Class to represent PVs that are bound to an adapter
+This class is very similar to Func
, in that
+it is the result of a binding operation between a user-specified PV
-object
+and a Device and/or Adapter object. Also, it should rarely be used directly. objects
+are generated automatically by EpicsAdapter
.
The binding happens by supplying a target
-object which has an attribute or a property
+named according to the property-name stored in the PV-object, and a meta_target
-object
+which has an attribute named according to the meta_data_property in PV.
The properties read_only
, config
, and poll_interval
simply forward the
+data of PV, while doc
uses the target object to potentially obtain the property’s
+docstring.
To get and set the value of the property on the target, the value
-property of
+this class can be used, to get the meta data dict, there’s a meta
-property.
pv – PV object to bind to target and meta_target.
target – Object that has an attribute named pv.property.
meta_target – Object that has an attribute named pv.meta_data_property.
Config dict passed on to pcaspy-machinery.
+Docstring of property on target or override specified on PV-object.
+Value of the bound meta-property on the target.
+Interval at which to update PV in pcaspy.
+True if the PV is read-only.
+Value of the bound property on the target.
+Bases: Adapter
This adapter provides ChannelAccess server functionality through the pcaspy module.
+It’s possible to configure the prefix for the PVs provided by this adapter. The
+corresponding key in the options
dictionary is called prefix
:
options = {"prefix": "PVPREFIX:"}
+
options – Dictionary with options.
+This property can be overridden in a sub-class to provide protocol documentation to users +at runtime. By default it returns the indentation cleaned-up docstring of the class.
+Call this method to spend about cycle_delay
seconds processing
+requests in the pcaspy server. Under load, for example when running caget
at a
+high frequency, the actual time spent in the method may be much shorter. This effect
+is not corrected for.
cycle_delay – Approximate time to be spent processing requests in pcaspy server.
+This property indicates whether the Adapter’s server is running and listening. The result
+of calls to start_server()
and stop_server()
should be reflected as expected.
Creates a pcaspy-server.
+Note
+The server does not process requests unless handle()
is called regularly.
This method must be re-implemented to stop and tear down anything that has been setup
+in start_server()
. This method should close all connections to clients that have
+been established since the adapter has been started.
Note
+This method may be called multiple times over the lifetime of the Adapter, so it is +important to make sure that this does not cause problems.
+Bases: InterfaceBase
Inheriting from this class provides an EPICS-interface to a device for use with
+EpicsAdapter
. In the simplest case all that is required is to inherit
+from this class and override the pvs
-member. It should be a dictionary
+that contains PV-names (without prefix) as keys and instances of PV as
+values. The prefix is handled by EpicsAdapter
.
For a simple device with two properties, speed and position, the first of which +should be read-only, it’s enough to define the following:
+class SimpleDeviceEpicsInterface(EpicsInterface):
+ pvs = {"VELO": PV("speed", read_only=True), "POS": PV("position", lolo=0, hihi=100)}
+
For more complex behavior, the interface could contain properties that do not +exist in the device itself. If the device should also have a PV called STOP +that “stops the device”, the interface could look like this:
+class SimpleDeviceEpicsInterface(EpicsInterface):
+ pvs = {
+ "VELO": PV("speed", read_only=True),
+ "POS": PV("position", lolo=0, hihi=100),
+ "STOP": PV("stop", type="int"),
+ }
+
+ @property
+ def stop(self):
+ return 0
+
+ @stop.setter
+ def stop(self, value):
+ if value == 1:
+ self.device.halt()
+
Even though the device does not have a property called stop
(but a method called
+halt
), issuing the command
$ caput STOP 1
+
will achieve the desired behavior, because EpicsInterface
merges the properties
+of the device into SimpleDeviceEpicsInterface
itself, so that it is does not
+matter whether the specified property in PV exists in the device or the adapter.
The intention of this design is to keep device classes small and free of +protocol specific stuff, such as in the case above where stopping a device +via EPICS might involve writing a value to a PV, whereas other protocols may +offer an RPC-way of achieving the same thing.
+Adapter type that is required to process and expose interfaces of this type. Must be +implemented in subclasses.
+Bases: object
The PV-class is used to declare the EPICS-interface exposed by a sub-class of
+EpicsAdapter. The target_property
argument specifies which property of the adapter
+the PV maps to. To make development easier it can also be a part of the exposed
+device. If the property exists on both the Adapter-subclass and the device, the former
+has precedence. This is useful for overriding behavior for protocol specific “quirks”.
If the PV should be read only, this needs to be specified via +the corresponding parameter. The information about the poll interval is used +py EpicsAdapter to update the PV in regular intervals. All other named arguments +are forwarded to the pcaspy server’s pvdb, so it’s possible to pass on +limits, types, enum-values and so on.
+In case those arguments change at runtime, it’s possible to provide meta_data_property
,
+which should contain the name of a property that returns a dict containing these values.
+For example if limits change:
class Interface(EpicsInterface):
+ pvs = {"example": PV("example", meta_data_property="example_meta")}
+
+ @property
+ def example_meta(self):
+ return {
+ "lolim": self.device._example_low_limit,
+ "hilim": self.device._example_high_limit,
+ }
+
The PV infos are then updated together with the value, determined by poll_interval
.
In cases where the device is accessed via properties alone, this class provides the possibility +to expose methods as PVs. A common use case would be to model a getter:
+class SomeDevice(Device):
+ def get_example(self):
+ return 42
+
+
+class Interface(EpicsInterface):
+ pvs = {"example": PV("get_example")}
+
It is also possible to model a getter/setter pair, in this case a tuple has to be provided:
+class SomeDevice(Device):
+ _ex = 40
+
+ def get_example(self):
+ return self._ex + 2
+
+ def set_example(self, new_example):
+ self._ex = new_example - 2
+
+
+class Interface(EpicsInterface):
+ pvs = {"example": PV(("get_example", "set_example"))}
+
Any of the two members in the tuple can be substituted with None
in case it does not apply.
+Besides method names it is also allowed to provide callables. Valid callables are for example
+bound methods and free functions, but also lambda expressions and partials.
There are however restrictions for the supplied functions (be it as method names or directly
+as callables) with respect to their signature. Getter functions must be callable without any
+arguments, setter functions must be callable with exactly one argument. The self
of
+methods does not count towards this.
target_property – Property or method name, getter function, tuple of getter/setter.
poll_interval – Update interval of the PV.
read_only – Should be True if the PV is read only. If not specified, the PV is +read_only if only a getter is supplied.
meta_data_property – Property or method name, getter function, tuple of getter/setter.
doc – Description of the PV. If not supplied, docstring of mapped property is used.
kwargs – Arguments forwarded into pcaspy pvdb-dict.
lewis.adapters
The core Adapter API is located in lewis.core.adapters
. This package contains concrete
+implementations of Adapter
in its submodules, along with
+specific tools for each adapter.
Submodules
++ | + |
+ | This module provides components to expose a Device via a Modbus-interface. |
+
+ | + |
lewis.adapters.modbus
This module provides components to expose a Device via a Modbus-interface. The following resources +were used as guidelines and references for implementing the protocol:
+++
Note
+For an example how Modbus can be used in the current implementation, please look +at lewis/examples/modbus_device.
+Members
++ | Modbus standard exception codes |
+
+ | + |
+ | A basic ModbusDataBank instance. |
+
+ | Preliminary DataBank implementation for Modbus. |
+
+ | Convenience struct to hold the four types of DataBanks in Modbus |
+
|
++ |
+ | + |
+ | This class implements the Modbus TCP Protocol. |
+
|
++ |
+ | This class models a frame of the Modbus TCP protocol. |
+
Bases: Adapter
This function is called on each cycle of a simulation. It should process requests that are
+made via the protocol that exposes the device. The time spent processing should be
+approximately cycle_delay
seconds, during which the adapter may block the current
+process. It is desirable to stick to the provided time, but deviations are permissible if
+necessary due to the way the protocol works.
cycle_delay – Approximate time spent processing requests.
+This property indicates whether the Adapter’s server is running and listening. The result
+of calls to start_server()
and stop_server()
should be reflected as expected.
This method must be re-implemented to start the infrastructure required for the +protocol in question. These startup operations are not supposed to be carried out on +construction of the adapter in order to preserve control over when services are +started during a run of a simulation.
+Note
+This method may be called multiple times over the lifetime of the Adapter, so it is +important to make sure that this does not cause problems.
+See also
+See stop_server()
for shutting down the adapter.
This method must be re-implemented to stop and tear down anything that has been setup
+in start_server()
. This method should close all connections to clients that have
+been established since the adapter has been started.
Note
+This method may be called multiple times over the lifetime of the Adapter, so it is +important to make sure that this does not cause problems.
+Bases: ModbusDataBank
A basic ModbusDataBank instance.
+This type of DataBank simply serves as a memory space for Modbus requests to read from and +write to. It does not support binding addresses to attributes or functions of the device +or interface. Example usage:
+di = ModbusBasicDataBank(False, 0x1000, 0x1FFF)
+
default_value – Value to initialize memory with
start_addr – First valid address
last_addr – Last valid address
Bases: object
Preliminary DataBank implementation for Modbus.
+This is a very generic implementation of a databank for Modbus. It’s meant to set the +groundwork for future implementations. Only derived classes should be instantiated, not +this class directly. The signature of this __init__ method is subject to change.
+kwargs – Configuration
+Bases: object
Convenience struct to hold the four types of DataBanks in Modbus
+Bases: InterfaceBase
Adapter type that is required to process and expose interfaces of this type. Must be +implemented in subclasses.
+Bases: object
This class implements the Modbus TCP Protocol.
+The user of this class should provide a ModbusDataStore instance that will be used to +fulfill read and write requests, and a callable sender which accepts one bytearray +parameter. The sender will be called whenever a response frame is generated, with a +bytearray containing the response frame as the parameter.
+Processing occurs when the user calls ModbusProtocol.process(), passing in the raw frame +data to process as a bytearray. The data may include multiple frames and partial frame +fragments. Any data that could not be processed (due to incomplete frames) is buffered for +the next call to process.
+sender – callable that accepts one bytearray parameter, called to send responses.
datastore – ModbusDataStore instance to reference when processing requests
Process as much of given data as possible.
+Any remainder, in case there is an incomplete frame at the end, is stored so that +processing may continue where it left off when more data is provided.
+data – Incoming byte data. Must be compatible with bytearray.
device_lock – threading.Lock instance that is acquired for device interaction.
Bases: object
This class models a frame of the Modbus TCP protocol.
+It may be a request, a response or an exception. Typically, requests are constructed using the +init method, while responses and exceptions are constructed by called create_request or +create_exception on an instance that is a request.
+Note that data from the passed in bytearray stream is consumed. That is, bytes will be removed +from the front of the bytearray if construction is successful.
+stream – bytearray to consume data from to construct this frame.
+EOFError – Not enough data for complete frame; no data consumed.
+Create an exception frame based on this frame.
+code – Modbus exception code to use for this exception
+ModbusTCPFrame instance that represents an exception
+Create a response frame based on this frame.
+data – Data section of response as bytearray. If None, request data section is kept.
+ModbusTCPFrame instance that represents a response
+Constructs this frame from input data stream, consuming as many bytes as necessary from +the beginning of the stream.
+If stream does not contain enough data to construct a complete modbus frame, an EOFError +is raised and no data is consumed.
+stream – bytearray to consume data from to construct this frame.
+EOFError – Not enough data for complete frame; no data consumed.
+lewis.adapters.stream
Members
++ | This class is an implementation of |
+
+ | + |
+ | Objects of this type connect a callable object to a pattern matcher ( |
+
+ | This class defines an interface for general command-matchers that use any kind of technique to match a certain request in string form. |
+
+ | The StreamAdapter is the bridge between the Device Interface and the TCP Stream networking backend implementation. |
+
+ | + |
+ | This class is used to provide a TCP-stream based interface to a device. |
+
|
++ |
+ | With this implementation of |
+
+ | Implementation of |
+
+ | Interprets the specified pattern as a scanf format. |
+
Bases: CommandBase
This class is an implementation of CommandBase
that can expose a callable object
+or a named method of the device/interface controlled by StreamAdapter
.
def random():
+ return 6
+
+SomeInterface(StreamInterface):
+ commands = {
+ Cmd(lambda: 4, pattern='^R$', doc='Returns a random number.'),
+ Cmd('random', pattern='^RR$', doc='Better random number.'),
+ Cmd(random, pattern='^RRR$', doc='The best random number.'),
+ }
+
+ def random(self):
+ return 5
+
The interface defined by the above example has three commands, R
which calls a lambda
+function that always returns 4, RR
, which calls SomeInterface.random
and returns 5 and
+lastly RRR
which calls the free function defined above and returns the best random number.
For a detailed explanation of requirements to the constructor arguments, please refer to the
+documentation of Func
, to which the arguments are forwarded.
See also
+Var
exposes attributes and properties of a device object. The documentation
+of Func
provides more information about the common constructor arguments.
func – Function to be called when pattern matches or member of device/interface.
pattern – Pattern to match (PatternMatcher
or string).
argument_mappings – Iterable with mapping functions from string to some type.
return_mapping – Mapping function for return value of method.
doc – Description of the command. If not supplied, the docstring is used.
Bases: object
This is the common base class of Cmd
and Var
. The concept of commands for
+the stream adapter is based on connecting a callable object to a pattern that matches an
+inbound request.
The type of pattern can be either an implementation of PatternMatcher
+(regex or scanf format specification) or a plain string (which is treated as a regular
+expression).
For free function and lambda expressions this is straightforward: the function object can +simply be stored together with the pattern. Most often however, the callable +is a method of the device or interface object - these do not exist when the commands are +defined.
+This problem is solved by introducing a “bind”-step in StreamAdapter
. So instead
+of a function object, both Cmd
and Var
store the name of a member of device
+or interface. At “bind-time”, this is translated into the correct callable.
So instead of using Cmd
or Var
directly, both classes’ bind()
-methods
+return an iterable of Func
-objects which can be used for processing requests.
+StreamAdapter
performs this bind-step when it’s constructed. For details regarding
+the implementations, please see the corresponding classes.
See also
+Please take a look at Cmd
for exposing callable objects or methods of
+device/interface and Var
for exposing attributes and properties.
To see how argument_mappings, return_mapping and doc are applied, please look at
+Func
.
func – Function to be called when pattern matches or member of device/interface.
pattern – Pattern to match (PatternMatcher
or string).
argument_mappings – Iterable with mapping functions from string to some type.
return_mapping – Mapping function for return value of method.
doc – Description of the command. If not supplied, the docstring is used.
Bases: object
Objects of this type connect a callable object to a pattern matcher (PatternMatcher
),
+which currently comprises regex
and scanf
. Strings are also
+accepted, they are treated like a regular expression internally. This preserves default
+behavior from older versions of Lewis.
In general, Func-objects should not be created directly, instead they are created by one of
+the sub-classes of CommandBase
using bind()
.
Function arguments are indicated by groups in the regular expression. The number of
+groups has to match the number of arguments of the function. In earlier versions of Lewis it
+was possible to pass flags to re.compile
, this has been removed for consistency issues
+in Var
. It is however still possible to use the exact same flags as part of the
+regular expression. In the documentation of re, this is outlined, simply add a group to the
+expression that contains the flags, for example (?i)
to make the expression case
+insensitive. This special group does not count towards the matching groups used for argument
+capture.
The optional argument_mappings can be an iterable of callables with one parameter of the
+same length as the number of arguments of the function. The first parameter will be
+transformed using the first function, the second using the second function and so on.
+This can be useful to automatically transform strings provided by the adapter into a proper
+data type such as int
or float
before they are passed to the function. In case the
+pattern is of type scanf
, this is optional (but will override the mappings
+provided by the matcher).
The return_mapping argument is similar, it should map the return value of the function
+to a string. The default map function only does that when the supplied value
+is not None. It can also be set to a numeric value or a string constant so that the
+command always returns the same value. If it is None
, the return value is not
+modified at all.
Finally, documentation can be provided by passing the doc-argument. If it is omitted, +the docstring of the bound function is used and if that is not present, left empty.
+func – Function to be called when pattern matches or member of device/interface.
argument_mappings – Iterable with mapping functions from string to some type.
return_mapping – Mapping function for return value of method.
doc – Description of the command. If not supplied, the docstring is used.
RuntimeError: If the function cannot be mapped for any reason.
+Returns the mapped function arguments. If no mapping functions are defined, the arguments +are returned as they were supplied.
+arguments – List of arguments for bound function as strings.
+Mapped arguments.
+Returns the mapped return_value of a processed request. If no return_mapping has been +defined, the value is returned as is. If return_mapping is a static value, that value +is returned, ignoring return_value completely.
+return_value – Value to map.
+Mapped return value.
+Bases: object
This class defines an interface for general command-matchers that use any kind of
+technique to match a certain request in string form. It is used by Func
to check
+whether a request can be processed using a function and to extract any function arguments.
Sub-classes must implement all defined abstract methods/properties.
+ +Number of arguments that are matched in a request.
+Mapping functions that can be applied to the arguments returned by match()
.
Tries to match the request against the internally stored pattern. Returns any matched +function arguments.
+request – Request to attempt matching.
+List of matched argument values (possibly empty) or None if not matching.
+The pattern definition used for matching a request.
+Bases: Adapter
The StreamAdapter is the bridge between the Device Interface and the TCP Stream networking +backend implementation.
+Available adapter options are:
++++
+- +
bind_address: IP of network adapter to bind on (defaults to 0.0.0.0, or all adapters)
- +
port: Port to listen on (defaults to 9999)
- +
telnet_mode: When True, overrides in- and out-terminator for CRNL (defaults to False)
options – Dictionary with options.
+This property can be overridden in a sub-class to provide protocol documentation to users +at runtime. By default it returns the indentation cleaned-up docstring of the class.
+Spend approximately cycle_delay
seconds to process requests to the server.
cycle_delay – S
+This property indicates whether the Adapter’s server is running and listening. The result
+of calls to start_server()
and stop_server()
should be reflected as expected.
Starts the TCP stream server, binding to the configured host and port. +Host and port are configured via the command line arguments.
+Note
+The server does not process requests unless
+handle()
is called in regular intervals.
This method must be re-implemented to stop and tear down anything that has been setup
+in start_server()
. This method should close all connections to clients that have
+been established since the adapter has been started.
Note
+This method may be called multiple times over the lifetime of the Adapter, so it is +important to make sure that this does not cause problems.
+Bases: async_chat
Bases: InterfaceBase
This class is used to provide a TCP-stream based interface to a device.
+Many hardware devices use a protocol that is based on exchanging text with a client via +a TCP stream. Sometimes RS232-based devices are also exposed this way via an adapter-box. +This adapter makes it easy to mimic such a protocol.
+This class has the following attributes which may be overridden by subclasses:
++++
+- +
protocol: What this interface is called for purposes of the -p commandline option. +Defaults to “stream”.
- +
in_terminator, out_terminator: These define how lines are terminated when transferred +to and from the device respectively. They are stripped/added automatically. +Inverse of protocol file InTerminator and OutTerminator. The default is
\\r
.- +
readtimeout: How many msec to wait for additional data between packets, once transmission +of an incoming command has begun. Inverse of ReadTimeout in protocol files. +Defaults to 100 (ms). Set to 0 to disable timeout completely.
- +
commands: A list of
CommandBase
-objects that define mappings between protocol +and device/interface methods/attributes.
By default, commands are expressed as regular expressions, a simple example may look like this:
+class SimpleDeviceStreamInterface(StreamInterface):
+ commands = [
+ Cmd('set_speed', r'^S=([0-9]+)$', argument_mappings=[int]),
+ Cmd('get_speed', r'^S\?$')
+ Var('speed', read_pattern=r'^V\?$', write_pattern=r'^V=([0-9]+)$')
+ ]
+
+ def set_speed(self, new_speed):
+ self.device.speed = new_speed
+
+ def get_speed(self):
+ return self.device.speed
+
The interface has two commands, S?
to return the speed and S=10
to set the speed
+to an integer value. It also exposes the same speed attribute as a variable, using auto-
+generated V?
and V=10
commands.
As in the lewis.adapters.epics.EpicsInterface
, it does not matter whether the
+wrapped method is a part of the device or of the interface, this is handled automatically when
+a new device is assigned to the device
-property.
In addition, the handle_error()
-method can be overridden. It is called when an exception
+is raised while handling commands.
Adapter type that is required to process and expose interfaces of this type. Must be +implemented in subclasses.
+Override this method to handle exceptions that are raised during command processing. +The default implementation does nothing, so that any errors are silently ignored.
+request – The request that resulted in the error.
error – The exception that was raised.
Bases: CommandBase
With this implementation of CommandBase
it’s possible to expose plain data attributes
+or properties of device or interface. Getting and setting a value are separate procedures
+which both have their own pattern, read_pattern and write_pattern to match a command each.
+Please note that write_pattern has to have exactly one group defined to match a parameter.
Due to this separation, parameters can be made read-only, write-only or read-write in the +interface:
+class SomeInterface(StreamInterface):
+ commands = {
+ Var('foo', read_pattern='^F$', write_pattern=r'^F=(\d+)$',
+ argument_mappings=(int,), doc='An integer attribute.'),
+ Var('bar' read_pattern='^B$')
+ }
+
+ foo = 10
+
+ @property
+ def bar(self):
+ return self.foo + 5
+
+ @bar.setter
+ def bar(self, new_bar):
+ self.foo = new_bar - 5
+
In the above example, the foo attribute can be read and written, it’s automatically converted +to an integer, while bar is a property that can only be read via the stream protocol.
+See also
+For exposing methods and free functions, there’s the Cmd
-class.
target_member – Attribute or property of device/interface to expose.
read_pattern – Pattern to match for getter (PatternMatcher
or string).
write_pattern – Pattern to match for setter (PatternMatcher
or string).
argument_mappings – Iterable with mapping functions from string to some type, +only applied to setter.
return_mapping – Mapping function for return value of method, +applied to getter and setter.
doc – Description of the command. If not supplied, the docstring is used. For plain data +attributes the only way to get docs is to supply this argument.
Bases: PatternMatcher
Implementation of PatternMatcher
that compiles the specified pattern into a regular
+expression.
Number of arguments that are matched in a request.
+Bases: regex
Interprets the specified pattern as a scanf format. Internally, the scanf package is used +to transform the format into a regular expression. Please consult the documentation of scanf +for valid pattern specifications.
+By default, the resulting regular expression matches exactly. Consider this example:
+exact = scanf("T=%f")
+not_exact = scanf("T=%f", exact_match=False)
+
The first pattern only matches the string T=4.0
, whereas the second would also match
+T=4.0garbage
. Please note that the specifiers like %f
are automatically turned into
+groups in the generated regular expression.
pattern – Scanf format specification.
exact_match – Match only if the entire string matches.
Mapping functions that can be applied to the arguments returned by match()
.
The pattern definition used for matching a request.
+lewis.core.adapters
This module contains Adapter
, which serves as a base class for concrete adapter
+implementations in lewis.adapters
. It also contains AdapterCollection
which can
+be used to store multiple adapters and manage them together.
Members
++ | Base class for adapters |
+
+ | A container to manage the adapters of a device |
+
+ | A dummy context manager that raises a RuntimeError when it's used. |
+
Bases: object
Base class for adapters
+This class serves as a base class for concrete adapter implementations that expose a device via
+a certain communication protocol. It defines the minimal interface that an adapter must provide
+in order to fit seamlessly into other parts of the framework
+(most importantly Simulation
).
Sub-classes should re-define the protocol
-member to something appropriate. While it is
+explicitly supported to modify it in concrete device interface implementations, it is good
+to have a default (for example epics
or stream
).
An adapter should provide everything that is needed for the communication via the protocol it
+defines. This might involve constructing a server-object, configuring it and starting the
+service (this should happen in start_server()
). Due to the large differences between
+protocols it is very hard to provide general guidelines here. Please take a look at the
+implementations of existing adapters (EpicsAdapter
,
+StreamAdapter
),to get some examples.
In principle, an adapter can exist on its own, but it only really becomes useful when a device
+is bound to it. To do this, assign an object derived from
+lewis.core.devices.DeviceBase
to the device
-property. Sub-classes have to
+implement _bind_device()
to achieve actual binding behavior.
It is possible to pass a dictionary with configuration options to Adapter. The keys of
+the dictionary are accessible as properties of the _options
-member. Only keys that are
+in the default_options
member of the class are accepted. Inheriting classes must override
+default_options
to be a dictionary with the possible options for the adapter.
Each adapter has a lock
member, which contains a NoLock
by default. To make
+device access thread-safe, any adapter should acquire this lock before interacting with
+the device (or interface). This means that before starting the server component of an Adapter,
+a proper Lock-object needs to be assigned to lock
.
options – Configuration options for the adapter.
+This property can be overridden in a sub-class to provide protocol documentation to users +at runtime. By default it returns the indentation cleaned-up docstring of the class.
+This function is called on each cycle of a simulation. It should process requests that are
+made via the protocol that exposes the device. The time spent processing should be
+approximately cycle_delay
seconds, during which the adapter may block the current
+process. It is desirable to stick to the provided time, but deviations are permissible if
+necessary due to the way the protocol works.
cycle_delay – Approximate time spent processing requests.
+The device property contains the device-object exposed by the adapter.
+The property can be set from the outside, at that point the adapter will
+call _bind_device()
(which is implemented in each adapter sub-class)
+and thus re-bind its commands etc. to call the new device.
This property indicates whether the Adapter’s server is running and listening. The result
+of calls to start_server()
and stop_server()
should be reflected as expected.
This method must be re-implemented to start the infrastructure required for the +protocol in question. These startup operations are not supposed to be carried out on +construction of the adapter in order to preserve control over when services are +started during a run of a simulation.
+Note
+This method may be called multiple times over the lifetime of the Adapter, so it is +important to make sure that this does not cause problems.
+See also
+See stop_server()
for shutting down the adapter.
This method must be re-implemented to stop and tear down anything that has been setup
+in start_server()
. This method should close all connections to clients that have
+been established since the adapter has been started.
Note
+This method may be called multiple times over the lifetime of the Adapter, so it is +important to make sure that this does not cause problems.
+Bases: object
A container to manage the adapters of a device
+This container is designed to keep all adapters that expose a device in one place and interact +with them in a uniform way.
+Adapters can be passed as arguments upon construction or added later on using
+add_adapter()
(and removed using remove_adapter()
). The available protocols can be
+queried using the protocols()
property.
Each adapter can be started and stopped separately by supplying protocol names to
+connect()
and disconnect()
, both methods accept an arbitrary number of arguments,
+so that any subset of the stored protocols can be handled at any time. Supplying no protocol
+names at all will start/stop all adapters. These semantics also apply for is_connected()
+and documentation.
This class also makes sure that all adapters use the same Lock for device interaction.
+args – List of adapters to add to the container
+Adds the supplied adapter to the container but raises a RuntimeError
if there’s
+already an adapter registered for the same protocol.
adapter – Adapter to add to the container
+Returns a dictionary that contains the options for the specified adapter. The dictionary +keys are the adapter protocols.
+args – List of protocols for which to list options, empty for all adapters.
+Dict of protocol: option-dict pairs.
+This method starts an adapter for each specified protocol in a separate thread, if the +adapter is not already running.
+args – List of protocols for which to start adapters or empty for all.
+This lock is passed to each adapter when it’s started. It’s supposed to be used to ensure
+that the device is only accessed from one thread at a time, for example during network IO.
+Simulation
uses this lock to block the device during the
+simulation cycle calculations.
Stops all adapters for the specified protocols. The method waits for each adapter thread +to join, so it might hang if the thread is not terminating correctly.
+args – List of protocols for which to stop adapters or empty for all.
+Returns the concatenated documentation for the adapters specified by the supplied +protocols or all of them if no arguments are provided.
+args – List of protocols for which to get documentation or empty for all.
+Documentation for all selected adapters.
+If only one protocol is supplied, a single bool is returned with the connection status. +Otherwise, this method returns a dictionary of adapter connection statuses for the supplied +protocols. If no protocols are supplied, all adapter statuses are returned.
+args – List of protocols for which to start adapters or empty for all.
+Boolean for single adapter or dict of statuses for multiple.
+List of protocols for which adapters are registered.
+Tries to remove the adapter for the specified protocol, raises a RuntimeError
if there
+is no adapter registered for that particular protocol.
protocol – Protocol to remove from container
+Bind the new device to all interfaces managed by the adapters in the collection.
+lewis.core.approaches
Defines functions that model typical behavior, such as a value approaching a target linearly at +a certain rate.
+Members
++ | This function returns the new value after moving towards target at the given speed constantly for the time dt. |
+
This function returns the new value after moving towards +target at the given speed constantly for the time dt.
+If for example the current position is 10 and the target is -20, +the returned value will be less than 10 if rate and dt are greater +than 0:
+new_pos = linear(10, -20, 10, 0.1) # new_pos = 9
+
The function makes sure that the returned value never overshoots:
+new_pos = linear(10, -20, 10, 100) # new_pos = -20
+
current – The current value of the variable to be changed.
target – The target value to approach.
rate – The rate at which the parameter should move towards target.
dt – The time for which to calculate the change.
The new variable value.
+lewis.core.control_client
This module provides client code for objects exposed via JSON-RPC over ZMQ.
+See also
+The server-part for these client classes is defined
+in the module control_server
.
Members
++ | This class provides an interface to a ControlServer instance on the server side. |
+
+ | This class serves as a base class for dynamically created classes on the client side that represent server-side objects. |
+
+ | An exception type for exceptions related to the transport protocol, i.e. malformed requests etc. |
+
+ | This exception type replaces exceptions that are raised on the server, but unknown (i.e. not in the exceptions-module) on the client side. |
+
Bases: object
This class provides an interface to a ControlServer instance on +the server side. Proxies to exposed objects can be obtained either +directly via get_object or, in case the server exposes a collection +of objects at the top level, a dictionary of named objects can be +obtained via get_object_collection.
+If a timeout is supplied, all underlying network operations time out
+after the specified time (in milliseconds), for no timeout specify None
.
host – Host the control server is running on.
port – Port on which the control server is listening.
timeout – Timeout in milliseconds for ZMQ operations.
If the remote end exposes a collection of objects under the supplied object name (empty +for top level), this method returns a dictionary of these objects stored under their +names on the server.
+This function performs n + 1 calls to the server, where n is the number of objects.
+object_name – Object name on the server. This is required if the object collection +is not the top level object.
+This method takes a ZMQ REQ-socket and submits a JSON-object containing +the RPC (JSON-RPC 2.0 format) to the supplied method with the supplied arguments. +Then it waits for a reply from the server and blocks until it has received +a JSON-response. The method returns the response and the id it used to tag +the original request, which is a random UUID (uuid.uuid4).
+method – Method to call on remote.
args – Arguments to method call.
JSON result and request id.
+Bases: object
This class serves as a base class for dynamically created classes on the +client side that represent server-side objects. Upon initialization, +this class takes the supplied methods and installs appropriate proxy methods +or properties into the object and class respectively. Because of that +class manipulation, this class must never be used directly. +Instead, it should be used as a base-class for dynamically created types +that mirror types on the server, like this:
+proxy = type("SomeClassName", (ObjectProxy,), {})(connection, methods, prefix)
+
There is however, the class ControlClient, which automates all that +and provides objects that are ready to use.
+Exceptions on the server are propagated to the client. If the exception is not part +of the exceptions-module (builtins for Python 3), a RemoteException is raised instead +which contains information about the server side exception.
+All RPC method names are prefixed with the supplied prefix, which is usually the +object name on the server plus a dot.
+connection – ControlClient-object for remote calls.
members – List of strings to generate methods and properties.
prefix – Usually object name on the server plus dot.
Bases: Exception
An exception type for exceptions related to the transport protocol, i.e. +malformed requests etc.
+Bases: Exception
This exception type replaces exceptions that are raised on the server, +but unknown (i.e. not in the exceptions-module) on the client side. +To retain as much information as possible, the exception type on the server and +the message are stored.
+exception_type – Type of the exception on the server side.
message – Exception message on the server side.
lewis.core.control_server
This module contains classes to expose objects via a JSON-RPC over ZMQ server. Lewis uses
+this infrastructure in Simulation
.
See also
+Client classes for the service defined in this module can be found in
+control_client
.
Members
++ | This server opens a ZMQ REP-socket at the given host and port when start_server is called. |
+
+ | ExposedObject is a class that makes it easy to expose an object via the JSONRPCResponseManager from the json-rpc package, where it can serve as a dispatcher. |
+
+ | This class helps expose a number of objects (plain or RPCObject) by exposing the methods of each object as |
+
Bases: object
This server opens a ZMQ REP-socket at the given host and port when start_server +is called.
+The server constructs an ExposedObjectCollection
from the supplied
+name: object-dictionary and uses that as a handler for JSON-RPC requests. If it is an
+instance of ExposedObject
, that is used directly.
Each time process is called, the server tries to get request data and responds to that. +If there is no data, the method does nothing.
+Please note that this RPC-service comes without any security, authentication, etc. +Only use it to expose objects on a trusted network and be aware that anyone on that +network can access the exposed objects without any restrictions.
+object_map – Dictionary with name: object-pairs to construct an +ExposedObjectCollection or ExposedObject
connection_string – String with host:port pair for binding control server.
The exposed object. This is a read only property.
+This property is True
if the server is running.
Each time this method is called, the socket tries to retrieve data and passes +it to the JSONRPCResponseManager, which in turn passes the RPC to the +ExposedObjectCollection.
+In case no data are available, the method does nothing. This behavior is required for +Lewis where everything is running in one thread. The central loop can call process +at some point to process remote calls, so the RPC-server does not introduce its own +infinite processing loop.
+If the server has not been started yet (via start_server()
), a RuntimeError
+is raised.
blocking – If True, this function will block until it has received data or a timeout +is triggered. Default is False to preserve behavior of prior versions.
+Bases: object
ExposedObject is a class that makes it easy to expose an object via the +JSONRPCResponseManager from the json-rpc package, where it can serve as a dispatcher. +For this purpose it exposes a read-only dict-like interface.
+The basic problem solved by this wrapper is that plain data members of an object are not +really captured well by the RPC-approach, where a client performs function calls on a +remote machine and gets the result back.
+The supplied object is inspected using dir(object) and all entries that do not start
+with a _ are exposed in a way that depends on whether the corresponding member
+is a method or a property (either in the Python-sense or the general OO-sense). Methods
+are stored directly, and stored in an internal dict where the method name is the key and
+the callable method object is the value. For properties, a getter- and a setter function
+are generated, which are then stored in the same dict. The names of these methods for
+a property called a
are a:get
and a:set
. The separator has been chosen to be
+colon because it can’t be part of a valid Python identifier.
If the second argument is not empty, it is interpreted to be the list of members +to expose and only those are actually exposed. This can be used to explicitly expose +members of an object that start with an underscore. If all but one or two members +should be exposed, it’s also possible to use the exclude-argument to explicitly +exclude a few members. Both parameters can be used in combination, the exclude-list +takes precedence.
+In certain situations it is desirable to acquire a lock before accessing the exposed object,
+for example when multiple threads are accessing it on the server side. For this purpose,
+the lock
-parameter can be used. If it is not None
, the exposed methods are wrapped
+in a function that acquires the lock before accessing obj
, and releases it afterwards.
obj – The object to expose.
members – This list of methods will be exposed. (defaults to all public members)
exclude – Members in this list will not be exposed.
exclude_inherited – Should inherited members be excluded? (defaults to False)
lock – threading.Lock
that is used when accessing obj
.
Bases: ExposedObject
This class helps expose a number of objects (plain or RPCObject) by +exposing the methods of each object as
+name.method
+
Furthermore it exposes each object’s API as a method with the following name:
+name: api
+
A list of exposed objects can be obtained by calling the following method from the client:
+:objects
+
named_objects – Dictionary of of name: object pairs.
+Adds the supplied object to the collection under the supplied name. If the name is already
+in use, a RuntimeError is raised. If the object is not an instance of
+ExposedObject
, the method automatically constructs one.
obj – Object to add to the collection.
name – Name of the exposed object.
lewis.core.devices
This module contains DeviceBase
as a base class for other device classes and
+infrastructure that can import devices from a module (DeviceRegistry
). The latter also
+produces factory-like objects that create device instances and interfaces based on setups
+(DeviceBuilder
).
Members
++ | This class is a common base for |
+
+ | This class takes a module object (for example imported via importlib.import_module or via the |
+
+ | This class takes the name of a module and constructs a |
+
+ | This class is a common base for protocol specific interfaces that are exposed by a subclass of |
+
+ | Returns True if obj is a device type (derived from DeviceBase), but not defined in |
+
+ | Returns True if obj is an interface (derived from |
+
Bases: object
This class is a common base for Device
and
+StateMachineDevice
. It is mainly used in the device
+discovery process.
Bases: object
This class takes a module object (for example imported via importlib.import_module or via the
+DeviceRegistry
) and inspects it so that it’s possible to construct devices and
+interfaces.
In order for the class to work properly, the device module has to adhere to a few rules.
+Device types, which means classes inheriting from DeviceBase
, are imported directly
+from the device module, equivalent to the following:
from device_name import SimulatedDeviceType
+
If SimulatedDeviceType
is defined in the __init__.py
, there’s nothing else to do. If
+the device class is defined elsewhere, it must be imported in the __init__.py
file as
+written above. If there is only one device type (which is probably the most common case), it is
+assumed to be default device type.
Setups are discovered in two locations, the first one is a dict called setups
in the device
+module, which must contain setup names as keys and as values again a dict. This inner dict has
+one mandatory key called device_type
and one optional key parameters
containing the
+constructor arguments for the specified device type:
setups = dict(
+ broken=dict(
+ device_type=SimulatedDeviceType,
+ parameters=dict(
+ override_initial_state="error",
+ override_initial_data=dict(target=-10, position=-20.0),
+ ),
+ )
+)
+
The other location is a sub-package called setups, which should in turn contain modules. Each
+module must contain a variable device_type
and a variable parameters
which are
+analogous to the keys in the dict described above. This allows for more complex setups which
+define additional classes and so on.
The default
setup is special, it is used when no setup is supplied to
+create_device()
. If the setup default
is not defined, one is created with the default
+device type. This has two consequences, no setups need to be defined for very simple devices,
+but if multiple device types are defined, a default
setup must be defined.
A setup can be supplied to the create_device()
.
Lastly, the builder tries to discover device interfaces, which are currently classes based on
+lewis.adapters.InterfaceBase
. These are looked for in the module and in a sub-package
+called interfaces
(which should contain modules with adapters like the setups
package).
Each interface has a protocol, if a protocol occurs more than once in a device module, +a RuntimeError is raised.
+Creates a device object according to the provided setup. If no setup is provided, +the default setup is used. If the setup can’t be found, a LewisException is raised. +This can also happen if the device type specified in the setup is invalid.
+setup – Name of the setup from which to create device.
+Device object initialized according to the provided setup.
+Returns an interface that implements the provided protocol. If the protocol is not
+known, a LewisException is raised. All additional arguments are forwarded
+to the interface constructor (see Adapter
for details).
protocol – Protocol which the interface must implement.
args – Positional arguments that are passed on to the interface.
kwargs – Keyword arguments that are passed on to the interface.
Instance of the interface type.
+If the module only defines one device type, it is the default device type. It is used
+whenever a setup does not provide a device_type
.
In case only one protocol exists for the device, this is the default protocol.
+This property contains a dict of all device types in the device module. The keys are +type names, the values are the types themselves.
+This property contains a map with protocols as keys and interface types as values.
+The types are imported from the interfaces
sub-module and from the device module
+itself. If two interfaces with the same protocol are discovered, a RuntimeError is raiesed.
The name of the device, which is also the name of the device module.
+All available protocols for this device.
+A map with all available setups. Setups are imported from the setups
dictionary
+in a device module and from the setups
sub-module. If no default
-setup exists,
+one is created using the default_device_type. If there are several device types in
+the module, the default setup must be provided explicitly.
Bases: object
This class takes the name of a module and constructs a DeviceBuilder
from
+each sub-module. The available devices can be queried and a DeviceBuilder can be
+obtained for each device:
from lewis.core.devices import DeviceRegistry
+
+registry = DeviceRegistry("lewis.devices")
+chopper_builder = registry.device_builder("chopper")
+
+# construct device, interface, ...
+
If the module can not be imported, a LewisException is raised.
+device_module – Name of device module from which devices are loaded.
+Returns a DeviceBuilder
instance that can be used to create device objects
+based on setups, as well as device interfaces. If the device name is not stored
+in the internal map, a LewisException is raised.
Each DeviceBuilder has a framework_version
-member, which specifies the version
+of Lewis the device has been written for. If the version does not match the current
+framework version, it is only possible to obtain those device builders calling the
+method with strict_versions
set to False
, otherwise a
+LewisException
is raised. A warning message is logged
+in all cases. If framework_version
is None
(e.g. not specified at all), it
+is accepted unless strict_versions
is set to True
.
name – Name of the device.
+DeviceBuilder
-object for requested device.
All available device names.
+Bases: object
This class is a common base for protocol specific interfaces that are exposed by a subclass of
+Adapter
. This base class is not meant to be used directly in
+a device package - this is what the interfaces in lewis.adapters
are for.
There is a 1:1 correspondence between device and interface, where the interface holds a
+reference to the device. It can be changed through the device
-property.
Adapter type that is required to process and expose interfaces of this type. Must be +implemented in subclasses.
+The device this interface is bound to. When a new device is set, _bind_device()
is
+called, where the interface can react to the device change if necessary.
Returns True if obj is a device type (derived from DeviceBase), but not defined in
+lewis.core.devices
or lewis.devices
.
obj – Object to test.
+True if obj is a device type.
+Returns True if obj is an interface (derived from InterfaceBase
), but not defined in
+lewis.adapters
, where concrete interfaces for protocols are defined.
obj – Object to test.
+True if obj is an interface type.
+lewis.core.exceptions
Defines exception types specific to lewis. The main intention of these exception types is +that they can be caught and meaningful messages can be displayed to the user.
+Members
++ | This exception can be raised in situation where the performed action (accessing a property or similar) is not allowed. |
+
+ | This exception type is used to distinguish exceptions that are expected from unexpected ones. |
+
+ | An exception that can be raised in a device to indicate a limit violation. |
+
Bases: Exception
This exception can be raised in situation where the performed action (accessing a property or
+similar) is not allowed. An example is BoundPV
for enforcing
+read-only PVs.
Bases: Exception
This exception type is used to distinguish exceptions that are expected +from unexpected ones. This enables better error handling and more importantly +better presentation of errors to the users.
+Bases: Exception
An exception that can be raised in a device to indicate a limit violation. It is for example
+raised by the check_limits
.
lewis.core
Submodules
++ | This module contains |
+
+ | Defines functions that model typical behavior, such as a value approaching a target linearly at a certain rate. |
+
+ | This module provides client code for objects exposed via JSON-RPC over ZMQ. |
+
+ | This module contains classes to expose objects via a JSON-RPC over ZMQ server. |
+
+ | This module contains |
+
+ | Defines exception types specific to lewis. |
+
+ | This module contains everything logging-related in Lewis. |
+
+ | This module defines two classes related to one of lewis' essential concepts, namely the cycle-based approach. |
+
+ | A |
+
+ | The statemachine module contains one of lewis' central parts, the cycle-based |
+
+ | This module contains some useful helper classes and functions that are not specific to a certain module contained in the Core API. |
+
lewis.core.logging
This module contains everything logging-related in Lewis. There is one relevant
+module level variable that defines the default log format, default_log_format
.
All places that use logging in Lewis prefix their logger names with lewis
so
+that you can easily control the logs caused by Lewis if you use it as a library.
+Lewis uses the default settings of the logging module, so if you use Lewis as a
+library and do not have any logging enabled, messages that are more severe than WARNING
+are printed to stdout. For details on how to disable that behavior, change levels
+for certain loggers and so on, please refer to the documentation
+of the standard logging library.
Members
++ | + |
+ | This is a decorator to add logging functionality to a class or function. |
+
This is a decorator to add logging functionality to a class or function.
+Applying this decorator to a class or function will add two new members:
++++
+- +
log
is an instance oflogging.Logger
. The name of the logger is +set tolewis.Foo
for a class named Foo.- +
_set_logging_context
is a method that modifies the name of the logger +when the class is used in a certain context.
If context
is a string, that string is directly inserted between lewis
+and Foo
, so that the logger name would be lewis.bar.Foo
if context
+was 'bar'
. The more common case is probably context
being an object of
+some class, in which case the class name is inserted. If context
is an object
+of type Bar
, the logger name of Foo
would be lewis.Bar.Foo
.
To provide a more concrete example in terms of Lewis, this is used for the state
+machine logger in a device. So the logs of the state machine belonging to a certain
+device appear in the log as originating from lewis.DeviceName.StateMachine
, which
+makes it possible to distinguish between messages from different state machines.
Example for how to use logging in a class:
+from lewis.core.logging import has_log
+
+
+@has_log
+class Foo(Base):
+ def __init__(self):
+ super(Foo, self).__init__()
+
+ def bar(self, baz):
+ self.log.debug("Called bar with parameter baz=%s", baz)
+ return baz is not None
+
It works similarly for free functions, although the actual logging calls are a bit different:
+from lewis.core.logging import has_log
+
+
+@has_log
+def foo(bar):
+ foo.log.info("Called with argument bar=%s", bar)
+ return bar
+
The name of the logger is lewis.foo
, the context could also be modified by calling
+foo._set_logging_context
.
target – Target to decorate with logging functionality.
+lewis.core.processor
This module defines two classes related to one of lewis’ essential concepts, namely
+the cycle-based approach. CanProcess
and CanProcessComposite
implement the
+composite design pattern so that it’s possible to form a tree of objects which can perform
+calculations based on an elapsed time Δt.
Members
++ | The CanProcess class is meant as a base for all things that are able to process on the basis of a time delta (dt). |
+
+ | This subclass of CanProcess is a convenient way of collecting multiple items that implement the CanProcess interface. |
+
Bases: object
The CanProcess class is meant as a base for all things that +are able to process on the basis of a time delta (dt).
+The base implementation does nothing.
+There are three methods that can be implemented by sub-classes and are called in the +process-method in this order:
++++
+- +
doBeforeProcess
- +
doProcess
- +
doAfterProcess
The doBefore- and doAfterProcess methods are only called if a doProcess-method exists.
+Bases: CanProcess
This subclass of CanProcess is a convenient way of collecting +multiple items that implement the CanProcess interface.
+Items can be added to the composite like this:
+composite = CanProcessComposite()
+composite.add_processor(item_that_implements_CanProcess)
+
The process-method calls the process-method of each contained +item. Specific things that have to be done before or after the +containing items are processed can be implemented in the doBefore- +and doAfterProcess methods.
+lewis.core.simulation
A Simulation
combines a Device
and its interface (derived from
+an Adapter
).
Members
++ | The Simulation class controls certain aspects of a device simulation, the most important one being time. |
+
+ | This class is used to create |
+
Bases: object
The Simulation class controls certain aspects of a device simulation, +the most important one being time.
+Once start()
is called, the process-method of the device
+is called in regular intervals. The time between these calls is
+influenced by the cycle_delay property. Because of the way some
+network protocols work, the actual processing time can be
+longer or shorter, so cycle_delay should be seen as a guideline
+rather than a guaranteed parameter.
In the simplest case, the actual time-delta between two cycles +is passed to the simulated device so that it can update its internal +state according to the elapsed time. It is however possible to set +a simulation speed, which serves as a multiplier for this time. +If the speed is set to 2 and 0.1 seconds pass between two cycles, +the simulation is asked to simulate 0.2 seconds, and so on. Speed 0 +effectively stops all time dependent calculations in the +simulated device.
+Another possibility to pause the simulation is the pause-method. After +calling it, all processing in the device is suspended, while the communication +adapters continue to work. This can be used to simulate that a device is “hanging”. +The simulation can be continued using the resume-method.
+A number of status properties provide information about the simulation. +The total uptime (in actually elapsed time) can be obtained through the +uptime-property, whereas the runtime-property contains the simulated time. +The cycles-property indicates the total number of simulation cycles, which +does not increase when the simulation is paused.
+Finally, the simulation can be stopped entirely with the stop-method.
+All functionality except for the start-method can be made available to remote
+computers via a ControlServer
-instance. The way to expose device and simulation
+is to pass a ‘host:port’-string as the control_server argument,
+which will construct the control server. Simulation will try to start the
+control server using the start_server method.
device – The simulated device.
adapters – Adapters which expose the simulated device.
device_builder – DeviceBuilder
instance to enable setup-
+switching at runtime.
control_server – ‘host:port’-string to construct control server or None.
ControlServer-instance that exposes the object to remote machines. Can only +be set before start has been called or on a running simulation if no +control server was previously present. If the server is not running, it will be started +after it has been set.
+Desired time between simulation cycles, this can not be negative. +Use 0 for highest possible processing rate.
+Simulation cycles processed since start has been called.
+True if the simulation is paused (implies that the simulation has been started).
+This property is true if the simulation has been started.
+Resume a paused simulation. Can only be called after start +and pause have been called.
+The accumulated simulation time. Whenever speed is different from 1, this +progresses at a different rate than uptime.
+Set multiple parameters of the simulated device “simultaneously”. The passed +parameter is assumed to be device parameter/value dict. +The method only allows to set existing attributes. If there are invalid +attribute names, the attributes are not updated, instead a RuntimeError +is raised. The same happens if any of the parameters are methods, which +can not be updated with this mechanisms.
+parameters – Dict of device attribute/values to update the device.
+A list of setups that are available. Use switch_setup()
to
+change the setup.
Simulation speed. Actual elapsed time is multiplied with this property +to determine simulated time. Values greater than 1 increase the simulation +speed, values between 1 and 0 decrease it. A speed of 0 effectively pauses +the simulation.
+This method switches the setup, which means that it replaces the currently +simulated device with a new device, as defined by the setup.
+If any error occurs during setup switching it is logged and re-raised.
+new_setup – Name of the new setup to load.
+Elapsed time in seconds since the simulation has been started.
+Bases: object
This class is used to create Simulation
-objects according to a certain
+set of parameters, such as device, setup and protocol. To create a simulation, it needs to
+know where devices are stored:
factory = SimulationFactory("lewis.devices")
+
The actual creation happens via the create()
-method:
simulation = factory.create("device_name", protocol="protocol")
+
The simulation can then be started and stopped as desired.
+Warning
+This class is meant for internal use at the moment and may change frequently.
+Creates a Simulation
according to the supplied parameters.
device – Name of device.
setup – Name of the setup for device creation.
protocols – Dictionary where each key is assigned a dictionary with options for the
+corresponding Adapter
. For available
+protocols, see get_protocols()
.
control_server – String to construct a control server (host:port).
Simulation object according to input parameters.
+Names of available devices.
+lewis.core.statemachine
The statemachine module contains one of lewis’ central parts, the cycle-based
+StateMachine
. The module also contains classes that make it easier to define the
+state machine (State
, Transition
). Despite its central nature, it’s unlikely
+to be used directly in client code for device simulations - these should be based on
+StateMachineDevice
, which provides a more convenient interface for that purpose.
Members
++ | Mixin to provide a Context. |
+
+ | StateMachine state handler base class. |
+
+ | Cycle based state machine. |
+
+ | Classes in this module should only raise this type of Exception. |
+
+ | StateMachine transition condition base class. |
+
Bases: object
Mixin to provide a Context.
+Creates a _context member variable that can be assigned with set_context()
.
Any state handler or transition callable that derives from this mixin will
+receive a context from its StateMachine
upon initialization (assuming the
+StateMachine was provided with a context itself).
Bases: HasContext
StateMachine state handler base class.
+Provides a way to implement StateMachine event handling behaviour using an +object-oriented interface. Once the StateMachine is configured to do so, it +will automatically invoke the events in this class when appropriate.
+To use this class, create a derived class and override any events that need
+custom behaviour. Device context is provided via HasContext
mixin.
Handle in-state event.
+Raised repeatedly, once per cycle, while idling in this state. Exactly one +in-state event occurs per cycle for every StateMachine. This is always the +last event of the cycle.
+dt – Delta T since last cycle.
+Bases: CanProcess
Cycle based state machine.
+cfg – dict which contains state machine configuration.
context – object which is assigned to State and Transition objects as their _context.
The configuration dict may contain the following keys:
++++
+- +
initial: Name of the initial state of this machine
- +
states: [optional] Dict of custom state handlers
- +
transitions: [optional] Dict of transitions in this state machine.
State handlers may be given as a dict, list or State class:
++++
+- +
dict: May contain keys ‘on_entry’, ‘in_state’ and ‘on_exit’.
- +
list: May contain up to 3 entries, above events in that order.
- +
class: Should be an instance of a class that derives from State.
In case of handlers being provided as a dict or a list, values should be callable +and may take a single parameter: the Delta T since the last cycle.
+Transitions should be provided as a dict where:
++++
+- +
Each key is a tuple of two values, the FROM and TO states respectively.
- +
Each value is a callable transition condition that return True or False.
Transition conditions are called once per cycle when in the FROM state. If one of +the transition conditions returns True, the transition is executed that cycle. The +remaining conditions aren’t called.
+Consider using an OrderedDict if order matters.
+Only one transition may occur per cycle. Every cycle will, at the very least, +trigger an in_state event against the current state.
+See also
+See doProcess()
for details.
Auto-bind state handlers based on naming convention.
+instance – Target object instance to search for handlers and bind events to.
override – If set to True, matching handlers will replace +previously registered handlers.
prefix – Dict or list of prefixes to override defaults +(keys: on_entry, in_state, on_exit)
This function enables automatically binding state handlers to events without having to +specify them in the constructor. When called, this function searches instance for +member functions that match the following patterns for all known states +(states mentioned in ‘states’ or ‘transitions’ dicts of cfg):
++++
+- +
instance._on_entry_[state]
- +
instance._in_state_[state]
- +
instance._on_exit_[state]
The default prefixes may be overridden using the prefix parameter. Supported keys are +‘on_entry’, ‘in_state’, and ‘on_exit’. Values should include any and +all desired underscores.
+Matching functions are assigned as handlers to the corresponding state events, +iff no handler was previously assigned to that event.
+If a state event already had a handler assigned (during construction or previous call +to this function), no changes are made even if a matching function is found. To force +previously assigned handlers to be overwritten, set the third parameter to True. +This may be useful to implement inheritance-like specialization using multiple +implementation classes but only one StateMachine instance.
+Returns true if the transition to ‘state’ is allowed from the current state.
+state – State to check transition to
+True if state is reachable from current
+Process a cycle of this state machine.
+dt – Delta T. “Time” passed since last cycle, passed on to event handlers.
+A cycle will perform at most one transition and exactly one in_state event.
+A transition will only occur if one of the transition condition functions leaving +the current state returns True.
+on_exit_old_state()
on_entry_new_state()
in_state_new_state()
The first cycle after init or reset will never call transition checks and, instead, +always performs on_entry and in_state on the initial state.
+Whether a transition occurs or not, and regardless of any other circumstances, a +cycle always ends by raising an in_state event on the current (potentially new) +state.
+Reset the state machine to before the first cycle. The next process() will +enter the initial state.
+Name of the current state.
+Bases: Exception
Classes in this module should only raise this type of Exception.
+Bases: HasContext
StateMachine transition condition base class.
+Provides a way to implement a transition that requires access to the device
+context. The device context is provided via HasContext
mixin, and can be
+accessed as self._context.
To use this class, create a derived class and override the __call__()
attribute.
lewis.core.utils
This module contains some useful helper classes and functions that are not specific to a certain +module contained in the Core API.
+Members
++ | This is a utility class for importing classes from a module or replacing them with dummy types if the module can not be loaded. |
+
+ | This decorator helps to make sure that the parameter of a property setter (or any other method with one argument) is within certain numerical limits. |
+
+ | This function updates base_dict with update_dict if and only if update_dict does not contain keys that are not already in base_dict. |
+
+ | This function tries to extract a valid module name from the basename of the supplied path. |
+
+ | A very thin wrapper around textwrap.fill to consistently wrap documentation text for display in a command line environment. |
+
+ | Returns all members of an object for which the supplied predicate is true and that do not begin with __. |
+
+ | This function imports all sub-modules of the supplied module and returns a dictionary with module names as keys and the sub-module objects as values. |
+
+ | This is a small helper function that returns the elapsed seconds since start using datetime.datetime.now(). |
+
Bases: object
This is a utility class for importing classes from a module or +replacing them with dummy types if the module can not be loaded.
+Assume module ‘a’ that does:
+from b import C, D
+
and module ‘e’ which does:
+from a import F
+
where ‘b’ is a hard to install dependency which is thus optional. +To still be able to do:
+import e
+
without raising an error, for example for inspection purposes, +this class can be used as a workaround in module ‘a’:
+C, D = FromOptionalDependency("b").do_import("C", "D")
+
which is not as pretty as the actual syntax, but at least it +can be read in a similar way. If the module ‘b’ can not be imported, +stub-types are created that are called ‘C’ and ‘D’. Everything depending +on these types will work until any of those are instantiated - in that +case an exception is raised.
+The exception can be controlled via the exception-parameter. If it is a +string, a LewisException is constructed from it. Alternatively it can +be an instance of an exception-type. If not provided, a LewisException +with a standard message is constructed. If it is anything else, a RuntimeError +is raised.
+Essentially, this class helps deferring ImportErrors until anything from +the module that was attempted to load is actually used.
+module – Module from that symbols should be imported.
exception – Text for LewisException or custom exception object.
Tries to import names from the module specified on initialization +of the FromOptionalDependency-object. In case an ImportError occurs, +the requested names are replaced with stub objects.
+names – List of strings that are used as type names.
+Tuple of actual symbols or stub types with provided names. If there is only one +element in the tuple, that element is returned.
+Bases: object
This decorator helps to make sure that the parameter of a property setter (or any other +method with one argument) is within certain numerical limits.
+It’s possible to set static limits using floats or ints:
+class Foo:
+ _bar = 0
+
+ @property
+ def bar(self):
+ return self._bar
+
+ @bar.setter
+ @check_limits(0, 15)
+ def bar(self, new_value):
+ self._bar = new_value
+
But sometimes this is not flexible enough, so it’s also possible to supply strings, which +are the names of attributes of the object the decorated method belongs with:
+class Foo:
+ _bar = 0
+
+ bar_min = 0
+ bar_max = 24
+
+ @property
+ def bar(self):
+ return self._bar
+
+ @bar.setter
+ @check_limits("bar_min", "bar_max")
+ def bar(self, new_value):
+ self._bar = new_value
+
This will make sure that the new value is always between bar_min
and bar_max
, even
+if they change at runtime. If the limit is None
(default), the value will not be limited
+in that direction.
Upper and lower limit can also be used exclusively, for example for a property that has a lower +bound but not an upper, say a temperature:
+class Foo:
+ _temp = 273.15
+
+ @check_limits(lower=0)
+ def set_temperature(self, t_in_kelvin):
+ self._temp = t_in_kelvin
+
If the value is outside the specified limits, the decorated function is not called and a
+LimitViolationException
is raised if the silent
-
+parameter is False
(default). If that option is active, the call is simply silently
+ignored.
lower – Numerical lower limit or name of attribute that contains limit.
upper – Numerical upper limit or name of attribute that contains limit.
silent – A limit violation will not raise an exception if this option is True
.
This function updates base_dict with update_dict if and only if update_dict does not contain +keys that are not already in base_dict. It is essentially a more strict interpretation of the +term “updating” the dict.
+If update_dict contains keys that are not in base_dict, a RuntimeError is raised.
+base_dict – The dict that is to be updated. This dict is modified.
update_dict – The dict containing the new values.
This function tries to extract a valid module name from the basename of the supplied path. +If it’s a directory, the directory name is returned, if it’s a file, the file name +without extension is returned. If the basename starts with _ or . or it’s a file with an +ending different from .py, the function returns None
+absolute_path – Absolute path of something that might be a module.
+Module name or None.
+A very thin wrapper around textwrap.fill to consistently wrap documentation text +for display in a command line environment. The text is wrapped to 99 characters with an +indentation depth of 4 spaces. Each line is wrapped independently in order to preserve +manually added line breaks.
+text – The text to format, it is cleaned by inspect.cleandoc.
+The formatted doc text.
+Returns all members of an object for which the supplied predicate is true and that do not +begin with __. Keep in mind that the supplied function must accept a potentially very broad +range of inputs, because the members of an object can be of any type. The function puts +those members into a dict with the member names as keys and returns it. If no predicate is +supplied, all members are put into the dict.
+obj – Object from which to get the members.
predicate – Filter function for the members, only members for which True is returned are +part of the resulting dict.
Dict with name-object pairs of members of obj for which predicate returns true.
+This function imports all sub-modules of the supplied module and returns a dictionary +with module names as keys and the sub-module objects as values. If the supplied parameter +is not a module object, a RuntimeError is raised.
+module – Module object from which to import sub-modules.
+Dict with name-module pairs.
+lewis.devices.chopper.devices.bearings
Members
+
|
++ |
|
++ |
|
++ |
lewis.devices.chopper.devices.device
Members
++ | + |
+ | + |
Bases: CanProcess
, MagneticBearings
Bases: StateMachineDevice
The current state of the chopper. This parameter is read-only, it is +determined by the internal state machine of the device.
+lewis.devices.chopper.devices.states
Members
++ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
|
++ |
Bases: State
Bases: State
Bases: State
Bases: State
Bases: State
lewis.devices.chopper
Submodules
++ | + |
+ | + |
lewis.devices.chopper.interfaces.epics_interface
Members
++ | ESS chopper EPICS interface |
+
Bases: EpicsInterface
ESS chopper EPICS interface
+Interaction with this interface should happen via ChannelAccess (CA). The PV-names +usually carry a prefix which depends on the concrete device and environment, so +it is omitted in this description. The dynamically generated description of the PVs +does however contain the prefix so that the names can be copy-pasted easily.
+The first step is to initialize the chopper, for example via caput on the command line:
+++$ caput CmdS init
+
After this, the chopper is in a state where it can be started:
+++$ caget State +State stopped
+
To set a specific speed and phase, the setpoints have to be configured via caput:
+++$ caput Spd 100 +$ caput Phs 34.5
+
Then the chopper can be commanded to move towards those values:
+++$ caput CmdS start
+
Now the disc accelerates to the setpoints, the state should now be different:
+++$ caget State +State accelerating
+
The possible commands are part of the PV-specific documentation.
+Command to execute. Possible commands are start, stop, set_phase, +unlock, park, init, deinit.
+The last command that was executed successfully.
+lewis.devices.chopper.interfaces
Submodules
++ | + |
lewis.devices
This module contains base classes for devices. Inherit from Device
for simple devices
+or from StateMachineDevice
for devices that are more complex and can be described
+using a state machine.
Submodules
++ | + |
+ | + |
+ | + |
Members
++ | This class exists mainly for consistency. |
+
+ | This class is intended to be sub-classed to implement devices using a finite state machine internally. |
+
Bases: DeviceBase
, CanProcess
This class exists mainly for consistency. It is meant to implement very simple devices that +do not require a state machine for their simulation. For such devices, all that is required +is subclassing from Device and possibly implementing doProcess, but this is optional.
+StateMachineDevice offers more functionality and is more likely to be useful for implementing +simulations of real devices.
+Bases: DeviceBase
, CanProcessComposite
This class is intended to be sub-classed to implement devices using a finite state machine +internally.
+Implementing such a device is straightforward, there are three methods +that must be overridden:
++++
+- +
_get_state_handlers()
- +
_get_initial_state()
- +
_get_transition_handlers()
The first method is supposed to return a dictionary with state handlers for each state +of the state machine, the second method must return the name of the initial state. +The third method must return a dict-like object (often an OrderedDict from collections) +that defines the conditions for transitions between the states of the state machine.
+They are implemented as methods and not as plain class member variables, because often +they use the self-variable, which does not exist at the class level.
+From these three methods, a StateMachine
-instance is
+constructed, it’s available as the device’s _csm
-member. CSM is short for
+“cycle-based state machine”.
Most device implementation will also want to override this method:
++++
+- +
_initialize_data()
This method should initialise device state variables (such as temperature, speed, etc.).
+Having this in a separate method from __init__
has the advantage that it can be used
+to reset those variables at a later stage, without having to write the same code again.
Following this scheme, inheriting from StateMachineDevice also provides the possibility +for users of the class to override the states, the transitions, the initial state and +even the data. For states, transitions and data, dicts need to be passed to the +constructor, for the initial state that should be a string.
+All these overrides can be used to define device setups to describe certain scenarios +more easily.
+override_states – Dict with one entry per state. Only states defined in the state +machine are allowed.
override_transitions – Dict with (state, state) tuples as keys and +callables as values.
override_initial_state – The initial state.
override_initial_data – A dict that contains data members +that should be overwritten on construction.
lewis.devices.julabo.devices.device
Members
++ | + |
Bases: StateMachineDevice
Sets whether to circulate - in effect whether the heater is on.
+param – The mode to set, must be 0 or 1.
+Empty string.
+Sets the external derivative. +Tv in Julabo speak.
+param – The value to set, must be an integer between 0 and 999
+Empty string.
+Sets the external integral. +Tn in Julabo speak.
+param – The value to set, must be an integer between 3 and 9999
+Empty string.
+Sets the external proportional. +Xp in Julabo speak.
+param – The value to set, must be between 0.1 and 99.9
+Empty string.
+Sets the internal derivative. +Tv in Julabo speak.
+param – The value to set, must be an integer between 0 and 999
+Empty string.
+Sets the internal integral. +Tn in Julabo speak.
+param – The value to set, must be an integer between 3 and 9999
+Empty string.
+lewis.devices.julabo
Submodules
++ | + |
+ | + |
lewis.devices.julabo.interfaces
Submodules
++ | + |
+ | + |
lewis.devices.julabo.interfaces.julabo_stream_interface_1
Members
++ | Julabos can have different commands sets depending on the version number of the hardware. |
+
Bases: StreamInterface
Julabos can have different commands sets depending on the version number of the hardware.
+This protocol matches that for: FP50_MH (confirmed).
+lewis.devices.julabo.interfaces.julabo_stream_interface_2
Members
++ | Julabos can have different commands sets depending on the version number of the hardware. |
+
Bases: StreamInterface
Julabos can have different commands sets depending on the version number of the hardware.
+This protocol matches that for: FP50-HE (unconfirmed).
+lewis.devices.linkam_t95.devices.device
Members
++ | + |
Bases: StateMachineDevice
lewis.devices.linkam_t95.devices.states
Members
++ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
Bases: State
lewis.devices.linkam_t95
Submodules
++ | + |
+ | + |
lewis.devices.linkam_t95.interfaces
Submodules
++ | + |
lewis.devices.linkam_t95.interfaces.stream_interface
Members
++ | Linkam T95 TCP stream interface. |
+
Bases: StreamInterface
Linkam T95 TCP stream interface.
+This is the interface of a simulated Linkam T95 device. The device listens on a configured +host:port-combination, one option to connect to it is via telnet:
+++$ telnet host port
+
Once connected, it’s possible to send the specified commands, described in the dynamically +generated documentation. Information about host, port and line terminators in the concrete +device instance are also generated dynamically.
+Models “Cool Command” functionality of device.
+Empty string.
+Models “T Command” functionality of device.
+Returns all available status information about the device as single byte array.
+Byte array consisting of 10 status bytes.
+If command is not recognised print and error
+request: requested string +error: problem
+Models “Heat Command” functionality of device.
+Empty string.
+Models “Hold Command” functionality of device.
+Device will hold current temperature until a heat or cool command is issued.
+Empty string.
+Models “LNP Pump Commands” functionality of device.
+Switches between automatic or manual pump mode, and adjusts speed when in manual mode.
+param – ‘a0’ for auto, ‘m0’ for manual, [0-N] for speed.
+Models “Limit Command” functionality of device.
+Sets the target temperate to be reached.
+param – Target temperature in C, multiplied by 10, as a string. Can be negative.
+Empty string.
+Models “Rate Command” functionality of device.
+Sets the target rate of temperature change.
+param – Rate of temperature change in C/min, multiplied by 100, as a string. Must be positive.
+Empty string.
+lewis.examples.dual_device
Members
++ | + |
+ | This is the EPICS interface to a quite simple device. |
+
+ | This is a TCP stream interface to the epics device, which only exposes param. |
+
Bases: Device
A second (floating point) parameter.
+Bases: EpicsInterface
This is the EPICS interface to a quite simple device. It offers 5 PVs that expose +different things that are part of the device, the interface or neither.
+The second parameter as an integer.
+Bases: StreamInterface
This is a TCP stream interface to the epics device, which only exposes param.
+lewis.examples.example_motor
Members
++ | + |
+ | TCP-stream based example motor interface |
+
+ | + |
Bases: StreamInterface
TCP-stream based example motor interface
+This motor simulation can be controlled via telnet:
+++$ telnet host port
+
Where the host and port-parameter are part of the dynamically created documentation for +a concrete device instance.
+The motor starts moving immediately when a new target position is set. Once it’s moving, +it has to be stopped to receive a new target, otherwise an error is generated.
+ + + + + + + + +Bases: StateMachineDevice
lewis.examples
Submodules
++ | + |
+ | + |
+ | + |
+ | + |
+ | + |
lewis.examples.modbus_device
Members
++ | The class attributes di, co, ir and hr represent Discrete Inputs, Coils, Input Registers and Holding Registers, respectively. |
+
+ | + |
Bases: ModbusInterface
The class attributes di, co, ir and hr represent Discrete Inputs, Coils, Input Registers and +Holding Registers, respectively. Each attribute should be assigned a ModbusDataBank instance +by the Interface implementation.
+Here, two basic ModbusDataBanks are created and initialized to a default value across the full +range of valid addresses. One DataBank is shared by di and co, and the other by ir and hr to +demonstrate overlaid memory segments. If you want each segment to have its own memory, just +create separate instances for all four.
+lewis.examples.simple_device
Members
++ | + |
+ | A very simple device with TCP-stream interface |
+
Bases: StreamInterface
A very simple device with TCP-stream interface
+The device has only one parameter, which can be set to an arbitrary +value. The interface consists of five commands which can be invoked via telnet. +To connect:
+++$ telnet host port
+
After that, typing either of the commands and pressing enter sends them to the server.
+The commands are:
+++ + ++
+- +
V
: Returns the parameter as part of a verbose message.- +
V=something
: Sets the parameter tosomething
.- +
P
: Returns the device parameter unmodified.- +
P=something
: Exactly the same asV=something
.- +
R
orr
: Returns the number 4.
Override this method to handle exceptions that are raised during command processing. +The default implementation does nothing, so that any errors are silently ignored.
+request – The request that resulted in the error.
error – The exception that was raised.
lewis.examples.timeout_device
Members
++ | + |
+ | A simple device where commands are terminated by a timeout. |
+
Bases: StreamInterface
A simple device where commands are terminated by a timeout.
+This demonstrates how to implement devices that do not have standard +terminators and where a command is considered terminated after a certain +time delay of not receiving more data.
+To interact with this device, you must switch telnet into char mode, or use +netcat with special tty settings:
+++$ telnet host port +^] +telnet> mode char +[type command and wait]
+$ stty -icanon && nc host port +hello world! +foobar!
+
The following commands are available:
+++
Override this method to handle exceptions that are raised during command processing. +The default implementation does nothing, so that any errors are silently ignored.
+request – The request that resulted in the error.
error – The exception that was raised.
lewis
Lewis - a library for creating hardware device simulators
+Submodules
++ | The core Adapter API is located in |
+
+ | + |
+ | This module contains base classes for devices. |
+
+ | + |
+ | + |
+ | This package contains helpful utilities for people that are building emulators. |
+
lewis.scripts.control
To interact with the control server of a running simulation, use this script. Usage:
+usage: lewis-control [-r RPC_HOST] [-t TIMEOUT] [-n] [-v] [-h]
+ [object] [member] [arguments ...]
+
+A client to manipulate the simulated device remotely through a separate
+channel. For this tool to be of any use, lewis must be invoked with the
+-r/--rpc-host option.
+
+Positional arguments:
+ object Object to control. If left out, all objects are
+ listed.
+ member Object-member to access. If omitted, API of the object
+ is listed.
+ arguments Arguments to method call. For setting a property,
+ supply the property value.
+
+Optional arguments:
+ -r RPC_HOST, --rpc-host RPC_HOST
+ HOST:PORT string specifying control server to connect
+ to.
+ -t TIMEOUT, --timeout TIMEOUT
+ Timeout after which the control client exits. Must be
+ at least as long as one simulation cycle.
+ -n, --print-none By default, no output is generated if the remote
+ function returns None. Specifying this flag will force
+ the client to print those None-values.
+ -v, --version Prints the version and exits.
+ -h, --h Shows this help message and exits.
+
Members
+
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
|
++ |
lewis.scripts
Submodules
++ | To interact with the control server of a running simulation, use this script. |
+
+ | This script is the main interaction point of the user with Lewis. |
+
Members
++ | This small helper function extracts the help information from an ArgumentParser instance and indents the text by the number of spaces supplied in the indent-argument. |
+
This small helper function extracts the help information from an ArgumentParser instance +and indents the text by the number of spaces supplied in the indent-argument.
+parser – ArgumentParser object.
indent – Number of spaces to put before each line or None.
Formatted help string of the supplied parser.
+lewis.scripts.run
This script is the main interaction point of the user with Lewis. The usage is as follows:
+usage: lewis [-s SETUP] [-n | -p ADAPTER_OPTIONS] [-l] [-L] [-i]
+ [-k DEVICE_PACKAGE] [-a ADD_PATH] [-c CYCLE_DELAY] [-e SPEED]
+ [-r RPC_HOST] [-o {none,critical,error,warning,info,debug}] [-V]
+ [-I] [-v] [-h] [-R]
+ [device]
+
+This script starts a simulated device that is exposed via the specified
+communication protocol. Complete documentation of Lewis is available in the
+online documentation on GitHub https://github.com/ess-dmsc/lewis/
+
+Positional arguments:
+ device Name of the device to simulate, omitting this argument
+ prints out a list of available devices.
+
+Device related parameters:
+ Parameters that influence the selected device, such as setup or protocol.
+
+ -s SETUP, --setup SETUP
+ Name of the setup to load. If not provided, the
+ default setup is selected. If thereis no default, a
+ list of setups is printed.
+ -n, --no-interface If supplied, the device simulation will not have any
+ communication interface.
+ -p ADAPTER_OPTIONS, --adapter-options ADAPTER_OPTIONS
+ Supply the protocol name and adapter options in the
+ format "name:{opt1: val, opt2: val}". Use the -l flag
+ to see which protocols are available for the selected
+ device. Can be supplied multiple times for multiple
+ protocols.
+ -l, --list-protocols List available protocols for selected device.
+ -L, --list-adapter-options
+ List available configuration options and their value.
+ Values that have not been modified in the -p argument
+ are default values.
+ -i, --show-interface Show command interface of device interface.
+ -k DEVICE_PACKAGE, --device-package DEVICE_PACKAGE
+ Name of packages where devices are found.
+ -a ADD_PATH, --add-path ADD_PATH
+ Path where the device package exists. Is added to the
+ path.
+
+Simulation related parameters:
+ Parameters that influence the simulation itself, such as timing and speed.
+
+ -c CYCLE_DELAY, --cycle-delay CYCLE_DELAY
+ Approximate time to spend in each cycle of the
+ simulation. 0 for maximum simulation rate.
+ -e SPEED, --speed SPEED
+ Simulation speed. The actually elapsed time between
+ two cycles is multiplied with this speed to determine
+ the simulated time.
+ -r RPC_HOST, --rpc-host RPC_HOST
+ HOST:PORT format string for exposing the device and
+ the simulation via JSON-RPC over ZMQ. Use lewis-
+ control to access this service from the command line.
+
+Other arguments:
+ -o {none,critical,error,warning,info,debug}, --output-level {none,critical,error,warning,info,debug}
+ Level of detail for logging to stderr.
+ -V, --verify Sets the output level to 'debug' and aborts before
+ starting the device simulation. This is intended to
+ help with diagnosing problems with devices or input
+ arguments.
+ -I, --ignore-versions
+ Ignore version mismatches between device and
+ framework. A warning will still be logged.
+ -v, --version Prints the version and exits.
+ -h, --help Shows this help message and exits.
+
+Deprecated arguments:
+ -R, --relaxed-versions
+ Renamed to --I/--ignore-versions. Using this old
+ option produces an error and it will be removed in a
+ future release.
+
Members
+
|
++ |
+ | This is effectively the main function of a typical simulation run. |
+
This is effectively the main function of a typical simulation run. Arguments passed in are +parsed and used to construct and run the simulation.
+This function only exits when the program has completed or is interrupted.
+argument_list – Argument list to pass to the argument parser declared in this module.
+lewis.utils.byte_conversions
Members
++ | Converts an floating point number to an unsigned set of bytes. |
+
+ | Converts an integer to an unsigned set of bytes with the specified length (represented as a string). |
+
+ | Convert a set of bytes to a floating point number |
+
+ | Converts an unsigned set of bytes to an integer. |
+
Converts an floating point number to an unsigned set of bytes.
+real_number – The float to convert.
low_byte_first – Whether to put the least significant byte first. True by default.
A string representation of the bytes.
+Converts an integer to an unsigned set of bytes with the specified length (represented as a string). Unless the +integer is negative in which case it converts to a signed integer.
+If low byte first is True, the least significant byte comes first, otherwise the most significant byte comes first.
+integer – The integer to convert.
length – The length of the result.
low_byte_first – Whether to put the least significant byte first.
string representation of the bytes.
+Convert a set of bytes to a floating point number
+raw_bytes – A string representation of the raw bytes.
+float: The floating point number represented by the given bytes.
+Converts an unsigned set of bytes to an integer.
+raw_bytes – A string representation of the raw bytes.
low_bytes_first – Whether the given raw bytes are in little endian or not. True by default.
The integer represented by the raw bytes passed in.
+lewis.utils.command_builder
A fluent command builder for lewis.
+Members
++ | Build a command for the stream adapter. |
+
Bases: object
Build a command for the stream adapter.
+Do this by creating this object, adding the values and then building it (this uses a fluent interface).
+For example to read a pressure the ioc might send “pres?” and when that happens this should call get_pres +command would be: +>>> CmdBuilder(“get_pres”).escape(“pres?”).build() +This will generate the regex needed by Lewis. The escape is just making sure none of the characters are special +reg ex characters. +If you wanted to set a pressure the ioc might send “pres <pressure>” where <pressure> is a floating point number, +the interface should call set_pres with that number. Now use: +>>> CmdBuilder(“set_pres”).escape(“pres “).float().build() +this add float as a regularly expression capture group for your argument. It is equivalent to: +>>> Cmd(“set_pres”, “pres ([+-]?d+.?d*)”) +There are various arguments like int and digit. Finally some special characters are included so if your protocol +uses enquirey character ascii 5 you can match is using +>>> CmdBuilder(“set_pres”).escape(“pres?”).enq().build()
+Create a builder. Use build to create the final object
+target_method – name of the method target to call when the reg ex matches
arg_sep – separators between arguments which are next to each other
ignore – set of characters to ignore between text and arguments
ignore_case – ignore the case when matching command
Add the ACK character (0x6) to the string.
+builder
+Add a single character based on its integer value, e.g. 49 is ‘a’.
+char_number – character number
+self
+Add an argument that matches anything.
+builder
+Adds an argument that matches anything other than a specified character (useful for commands containing +delimiters)
+char – the character not to match
+builder
+Add an argument to the command.
+arg_regex – regex for the argument (capture group will be added)
argument_mapping – the type mapping for the argument (default is str)
builder
+Builds the CMd object based on the target and regular expression.
+args – arguments to pass to Cmd constructor
kwargs – key word arguments to pass to Cmd constructor
Cmd object
+Add a single character argument.
+not_chars – characters that the character can not be; None for can be anything
ignore – True to match with a char but ignore the returned value (default: False)
builder
+Add a single digit argument.
+mapping – The type to cast the response to (default: int)
ignore – True to match with a digit but ignore the returned value (default: False)
builder
+Add the ENQ character (0x5) to the string.
+builder
+Matches one of a set of specified strings.
+allowed_values – the values this function is allowed to match
+builder
+Adds the regex end-of-string character to a command.
+builder
+Add the EOT character (0x4) to the string.
+builder
+Add some text to the regex which is escaped.
+text – text to add
+builder
+Add the ETX character (0x3) to the string.
+builder
+Add a float argument.
+mapping – The type to cast the response to (default: float)
ignore – True to match with a float but ignore the returned value (default: False)
builder
+Allows emulator to split multiple commands separated by a defined command separator, e.g. “;”. +Must be accompanied by stream device methods. See Keithley 2700 for examples
+command_separator – Character(s) that separate commands
+builder
+Add an integer argument.
+mapping – The type to cast the response to (default: int)
ignore – True to match with a int but ignore the returned value (default: False)
builder
+Add some escaped text which does not necessarily need to be there. For commands with optional parameters +:param text: Text to add +:return: builder
+Add a regex to match but not as an argument.
+new_regex – regex to add
+builder
+Add a regex for any number of spaces
+at_least_one – true there must be at least one space; false there can be any number including zero
+builder
+Add an argument which is a string of a given length (if blank string is any length)
+length – length of string; None for any length
+builder
+Add the STX character (0x2) to the string.
+builder
+lewis.utils.constants
List of constants which are useful in communications
+lewis.utils
This package contains helpful utilities for people that are building emulators.
+Submodules
++ | + |
+ | A fluent command builder for lewis. |
+
+ | List of constants which are useful in communications |
+
+ | + |
lewis.utils.replies
Members
++ | Decorator that executes the command and replies if the device has a member called 'property name' and it is True in a boolean context. |
+
+ | Decorator that inhibits a command and performs an action if call time is less than some minimum time delay between the current and last input. |
+
Decorator that executes the command and replies if the device has a member called +‘property name’ and it is True in a boolean context.
+Example usage:
+@conditional_reply("connected")
+def acknowledge_pressure(channel):
+ return ACK
+
property_name – The name of the property to look for on the device
reply – Desired output reply string when condition is false
The function returns as normal if property is true. +The command is not executed and there is no reply if property is false
+:except AttributeError if the first argument of the decorated function (self) +does not contain .device or ._device +:except AttributeError if the device does not contain a property called property_name
+Decorator that inhibits a command and performs an action if call time is less than +some minimum time delay between the current and last input.
+Example usage:
+@timed_reply(action="crash_pump", reply="WARNING: Input too quick", minimum_time_delay=150)
+def acknowledge_pressure(channel):
+ return ACK
+
action – The name of the method to execute for on the device
reply – Desired output reply string when input time delay is less than the minimum
minimum_time_delay – The minimum time (ms) between commands sent to the device
The function returns as normal if minimum delay exceeded. +The command is not executed and the action method is called on the device instead
+does not contain .device or ._device
+:except AttributeError if the device does not contain a property called action
++ | + |
+ | + |
+ | + |
|
+
|
+
+ | + |
+ | + |
+ | + |
+ |
+ | + |
Lewis is a Python package that makes it easy to develop complex stateful device simulations. It +is licensed under GPL version 3 and the source is available on github, where you are welcome to +open new issues so the package can improve.
+Documentation contents:
+Quickstart
+ +User guide
+ +Developer guide
+ +Release notes
+ +API reference
+lewis
lewis.adapters
+lewis.core
+lewis.devices
+lewis.examples
+lewis.scripts
+lewis.utils
+This section aims to get you started with Lewis as quickly as possible. It is meant as a basic starting point for becoming familiar with Lewis, and to give you a broad overview of what it can do. As such, many features are skimmed over or skipped entirely. See the detailed documentation sections for a more complete overview of features.
+This guide is presented as a step-by-step tutorial, so skipping sections may mean you will miss steps that are required for the examples to work.
+The recommended way to install Lewis is via PyPI and using a virtual environment. This guide assumes you have Python and Pip installed and in your PATH.
+Create a virtual environment (optional)
Install Lewis from PyPI, $ pip install lewis
Once Lewis is installed, you can use it to start some of the example devices it ships with.
+You can see which devices are available by just executing Lewis without parameters:
+$ lewis
+Please specify a device to simulate. The following devices are available:
+ julabo
+ chopper
+ linkam_t95
+
Some additional, simpler examples are located in the lewis.examples
module. You can tell Lewis which module to scan for devices using the -k
parameter:
$ lewis -k lewis.examples
+Please specify a device to simulate. The following devices are available:
+ dual_device
+ simple_device
+ timeout_device
+ modbus_device
+ example_motor
+
For this guide, we will launch the example_motor:
+$ lewis -k lewis.examples example_motor
+INFO lewis.DeviceBase: Creating device, setting up state machine
+INFO lewis.Simulation: Changed cycle delay to 0.1
+INFO lewis.Simulation: Changed speed to 1.0
+INFO lewis.Simulation: Starting simulation
+INFO lewis.AdapterCollection: Connecting device interface for protocol 'stream'
+INFO lewis.ExampleMotorStreamInterface.StreamServer: Listening on 0.0.0.0:9999
+
The example motor is a TCP Stream device and listens on port 9999 on all adapters by default.
+With the last command from the previous section still running, open another terminal window.
+Since the example motor conveniently uses CRLF line terminators, we can use telnet to talk to it:
+$ telnet localhost 9999
+Trying 127.0.0.1...
+Connected to localhost.
+Escape character is '^]'.
+
You’re now connected to the TCP Stream interface of the example motor device. It understands the following commands:
+Command |
+Meaning |
+
---|---|
S? |
+get status |
+
P? |
+get position |
+
T? |
+get target |
+
T=%f |
+set target |
+
H |
+stop movement |
+
You can get more details, and details on the interface of any device, by using the -i
or --show-interface
argument:
$ lewis -k lewis.examples example_motor -i
+
Note that the commands are case sensitive. Try entering a few commands in the Telnet session:
+S?
+idle
+P?
+0.0
+T=20.0
+T=20.0
+S?
+moving
+P?
+9.106584
+
See the source code of the example motor if you want to see what makes it tick.
+In addition to the simulated TCP Stream interface, Lewis provides a so-called Control Server interface, which allows you to bypass the normal device protocol and access both device and simulation parameters directly while the simulation is running. This can be very useful for debugging and diagnostics, without having to modify the main device interface.
+Remote access is disabled by default and enabled only if you provide the -r
argument when starting Lewis. Stop the previously launched instance of Lewis by pressing Ctrl-C
and run Lewis again with the -r
parameter to enable remote access like this:
$ lewis -r localhost:10000 -k lewis.examples example_motor
+
Lewis ships with a Control Client commandline tool that allows you to connect to it. It also has an -r
argument but for the client it defaults to localhost:10000
, which is why it is recommended to use the same value above.
Open another terminal session. If you installed Lewis in a virtual environment, make sure to activate it in the new terminal session so that Lewis is available.
+Running lewis-control
without any parameter displays the objects available to interact with:
$ lewis-control
+device
+interface
+simulation
+
You can think of these as root nodes in a tree that lewis-control
allows you to traverse. Passing one of them as an argument shows you what is available below that level:
$ lewis-control device
+Type: SimulatedExampleMotor
+Properties (current values):
+ position (20.0)
+ speed (2.0)
+ state (idle)
+ target (20.0)
+Methods:
+ stop
+
Going down one more level retrieves the value of a single property, or calls a method (without passing arguments):
+$ lewis-control device target
+0.0
+
And by specifying additional argument(s) we can set properties (or pass arguments to methods):
+$ lewis-control device target 100.0
+$ lewis-control device
+Type: SimulatedExampleMotor
+Properties (current values):
+ position (29.159932)
+ speed (2.0)
+ state (moving)
+ target (100.0)
+Methods:
+ stop
+$ lewis-control device stop
+[78.64038600000002, 78.64038600000002]
+$ lewis-control device
+Type: SimulatedExampleMotor
+Properties (current values):
+ position (78.640386)
+ speed (2.0)
+ state (idle)
+ target (78.640386)
+Methods:
+ stop
+
Note that, as you go along, you can also use a Telnet session in another terminal to issue commands or request information, and that the state of the device will be consistent between the two connections.
+Aside from the simulated device itself, you can also access and modify parameters of the simulation and network interface(s):
+$ lewis-control simulation
+$ lewis-control interface
+
See the respective sections of documentation for more details.
+While the command line client is convenient for manual diagnostics and debugging, you may find the Control API more useful for automated testing. It exposes all the same functionality available on the CLI via a Python library (In fact, that is how the CLI client is implemented).
+If you installed Lewis in a virtual environment, make sure you activate it:
+$ . myenv/bin/activate
+
Usually, you would use this API to write a Python script, but for demo purposes we will just use the interactive Python client:
+$ python
+>>> from lewis.core.control_client import ControlClient
+>>>
+>>> client = ControlClient(host='localhost', port='10000')
+>>> motor = client.get_object('device')
+>>>
+>>> motor.target
+78.64038600000002
+>>> motor.target = 20.0
+>>> motor.state
+u'moving'
+>>> motor.stop()
+[45.142721999999964, 45.142721999999964]
+>>> motor.state
+u'idle'
+>>> motor.position
+45.142721999999964
+
As with the previous sections, you can also interact with the motor using any of the other interfaces as you are doing this and the state will always be consistent between them.
+The initial release of Lewis (at that point still plankton). These release notes have been +compiled after the release as we were not keeping any release notes at that time.
+Cycle-based, deterministic device simulations based on finite state machines
Control over the simulation’s time granularity and speed (slow motion, fast forward)
Simulation and device control via command line and via optional network service
Two ready to use simulated devices using different protocols:
+ESS chopper abstraction (CHIC) using EPICS Channel Access
Linkam T95 temperature controller using TCP Stream protocol
Documentation in Markdown format for viewing in Github, both for users and developers
Examples for implementing Stream protocol based devices
This release is the first one under the name “Lewis”. Its main purpose is to make a PyPI package +available as well as online documentation under the new name.
+Nevertheless version 1.0.1 fixes some bugs and introduces a few new features that were originally +scheduled for release 1.1 but had already been finished at the time of the release.
+It is now possible to obtain device interface documentation via the command line
+and the control server, making it easier to communicate with unfamiliar devices.
+For command line invocation there is a new flag: lewis -i linkam_t95
.
+Thanks to David Michel
for requesting this feature.
Lewis is now available as a PyPI
-package and can be installed via pip install lewis
.
Documentation is now generated via Sphinx and has been made available online on RTD
_.
The control server can now be bound to a hostname instead of an IP-address (very useful
+for localhost
in particular).
pcaspy is now an optional requirement that has to be enabled explicitly in the requirements.txt
+file or installation via pip install lewis[epics]
.
Error messages displayed on the command line have been improved.
A flake8 job has been added to the continuous integration pipeline to enforce Python +style guidelines in the codebase.
This version of Lewis was released on January 26th, 2017. A few bugs have been fixed and a lot +of functionality has been added. The most notable changes are preliminary Modbus protocol support +and logging capabilities which make debugging easier.
+A preliminary Modbus Adapter has been added. The current version is mainly aimed at what is
+currently required by the IBEX team for the nanodac
. Since all that is needed is writing
+and reading back from memory via the Modbus protocol, bindings to Device
attributes or
+functions have not been implemented yet. We will add these in a future version.
The current version supports:
+Eight common Function Codes (0x01 through 0x06, 0x0F and 0x10)
Overlaid memory segments (using the same databank for di
and co
for example)
Modbus Exceptions for invalid Function Codes, bad memory addresses, invalid data, etc
Request frames may arrive in arbitrary chunks of multiple or partial frames
For a usage example, see examples/modbus_device
.
Logging capabilities have been added to the framework through the standard Python logging
_
+module. The lewis
-script logs messages to stderr, the level can be set using a new flag
+-o/--output-level
.
All devices have a new member log
, which can be used like this:
class SomeDevice(Device):
+ def some_method(self, param):
+ self.log.debug('some_method called with param=%s', param)
+
This new behavior is also supported by lewis.core.statemachine.State
,
+so that changes in device state can be logged as well.
A simulation for a Julabo FP50 waterbath was kindly contributed by Matt Clarke. It is +communicating through TCP Stream and offers two different protocol versions. The new device +can be started like the other available devices:
+$ lewis -p julabo-version-1 julabo
+
Exposing devices via TCP Stream has been made easier. It is now possible to define commands
+that expose lambda-functions, named functions and data attributes (with separate read/write
+patterns). See the updated documentation of :mod:lewis.adapters.stream
.
TCP Stream based devices are now easier to test with telnet due to a new adapter argument.
+The new -t
-flag makes the device interface “telnet compatible”:
$ lewis linkam_t95 -- -t
+
Instead of the native in- and out-terminator of the device, the interface now looks for \r\n
.
The lewis.adapters.epics.PV
-class has been extended to allow for meta data updates
+at runtime. A second property can now be specified that returns a dictionary to update the
+PV’s metadata such as limits or alarm states.
It is now possible to change multiple device parameters through lewis-control:
$ lewis-control simulation set_device_parameters "{'target_speed': 1, 'target_phase': 20}"
+
Thanks to the IBEX team for requesting this.
+Virtually disconnecting devices via the control server now actually closes all network +connections and shuts down any running servers, making it impossible to re-connect to the +device in that state. Virtually re-connecting the device returns the behavior back to normal.
If a device contained members that are not JSON serializable, displaying the device’s API +using the lewis-control script failed. This has been fixed, instead a message is now printed +that informs the user about why fetching the attribute value failed. Thanks to Adrian Potter +for reporting this issue.
This version was released on March 24th, 2017. In this release, the lewis.adapters.epics
-
+module has received some updates. Some important groundwork for future improvements has been
+laid as well, which resulted in the ability to switch device setups at runtime via the control
+server and a new command line syntax for configuring communications. The control server and client
+have been improved as well.
The way options are passed to the adapters has changed completely, the functionality has been
+merged into the -p
-argument, which has a new long version now, --adapter-options
.
For the default adapter options, it is still possible to use the lewis
-command with -p
+in the same way as before:
$ lewis -p stream linkam_t95
+
To supply options, such as the address and port to bind to, the argument accepts an extended +dictionary syntax now:
+$ lewis linkam_t95 -p "stream: {bind_address: localhost, port: 9998}"
+
The space after each colon is significant, it can not be left out. For strings containing +special characters, such as colons, it is necessary to quote them:
+$ lewis chopper -p "epics: {prefix: 'PREF:'}"
+
To see what options can be specified, use the new -L/--list-adapter-options
flag:
$ lewis chopper -p epics -L
+
Writing devices with an EPICS interface has been made more convenient for cases where the device
+does not have properties, but does have getter and setter methods.
+lewis.adapters.epics.PV
has been extended to accept a wider range of values for
+target_property
and meta_data_property
, for example method names:
class FooDevice(Device):
+ _foo = 3
+
+ def get_foo(self):
+ return self._foo * 3
+
+ class FooDeviceInterface(EpicsAdapter):
+ pvs = {
+ 'Foo': PV('get_foo')
+ }
+
For read/write cases, a tuple of names can be supplied. Instead of method names it is also
+allowed to specify callables, for example functions or lambda expressions. In that case, the
+signature of the function is checked. See also the new example in
+lewis.examples.epics_device
.
The device setup (specified in the setups
-dict or module inside the device module)
+can be changed at runtime through the control server. It is not possible to switch to
+another device, only setups of the same device can be used. To query available setups:
$ lewis-control simulation setups
+
Then, to actually activate the new setup, assuming it is called new_setup
:
$ lewis-control simulation switch_setup new_setup
+
It has been made easier to deposit devices in an external module while maintaining control over +compatibility with the rest of the Lewis-framework. Lewis now checks for a version specification +in each device module against the framework version before obtaining devices, adapters and +setups from it. Please add such a version specification to your devices.
+This way using devices from different sources becomes more reliable for users with different
+versions of Lewis, or hint them to update. By default, Lewis won’t start if a device specifies
+another framework version, but this behavior can be overridden by using the new flag
+-R/--relaxed-versions
:
In this case the simulation will start, but a warning will still be logged so that this can be +identified as a potential source of errors later on.
+A new flag -V/--verify
has been added to the lewis
-script. When activated, it sets
+the output level to debug
and exits before actually starting the simulation. This can
+help diagnose problems with device modules or input parameters.
The functionality for disconnecting and reconnecting a device’s communication interfaces that
+used to be accessible via lewis-control
through the simulation
has been moved into a
+separate channel called interface
. To disconnect a device use:
In general, more fine-grained control over the device’s communication is now possible.
+Both lewis.core.control_server.ControlServer
and
+lewis.core.control_client.ControlClient
were subject to some improvements, most
+notably a settable timeout for requests was added so that incomplete requests do not cause the
+client to hang anymore. In lewis-control
script, a new -t/--timeout
argument was added
+to make use of that new functionality.
Only members defined as part of the device class are listed when using lewis-control device
.
+lewis-control
generally no longer lists inherited framework functions such as log
,
+add_processor
, etc.
The following changes have to be made to upgrade code working with Lewis 1.0.2
to work with
+Lewis 1.0.3
:
Any scripts or code starting Lewis with the old style adapter parameters need to be updated to +the new style adapter options.
+For EPICS adapters:
+ Old style:
+ $ lewis chopper
+ $ lewis chopper -p epics
+ $ lewis chopper -p epics -- -p SIM:
+ $ lewis chopper -- --prefix SIM:
+ New style:
+ $ lewis chopper
+ $ lewis chopper -p epics
+ $ lewis chopper -p "epics: {prefix: 'SIM:'}"
+
For TCP Stream adapters:
+ Old style:
+ $ lewis linkam_t95
+ $ lewis linkam_t95 -p stream
+ $ lewis linkam_t95 -p stream -- -b 127.0.0.1 -p 9999 -t
+ $ lewis linkam_t95 -- --bind_address 127.0.0.1 --port 9999 --telnet_mode
+ New style:
+ $ lewis linkam_t95
+ $ lewis linkam_t95 -p stream
+ $ lewis linkam_t95 -p "stream: {bind_address: 127.0.0.1, port: 9999, telnet_mode: True}"
+
For Modbus adapters:
+ Old style:
+ $ lewis -k lewis.examples modbus_device
+ $ lewis -k lewis.examples modbus_device -p modbus
+ $ lewis -k lewis.examples modbus_device -p modbus -- -b 127.0.0.1 -p 5020
+ $ lewis -k lewis.examples modbus_device -- --bind_address 127.0.0.1 --port 5020
+ New style:
+ $ lewis -k lewis.examples modbus_device
+ $ lewis -k lewis.examples modbus_device -p modbus
+ $ lewis -k lewis.examples modbus_device -p "modbus: {bind_address: 127.0.0.1, port: 5020}"
+
Devices must now specify a framework_version
in the global namespace of their top-level
+__init__.py
, like this:
framework_version = '1.0.3'
+
This will need to be updated with every release. If this version is missing or does not match +the current Lewis framework version, attempting to run the device simulation will fail with a +message informing the user of the mismatch. This can be bypassed by starting Lewis with the +following parameter:
+ $ lewis linkam_t95 -R
+ $ lewis linkam_t95 --relaxed-versions
+
Warning: in the next release, specifying framework_version
becomes optional and
+--relaxed-versions
is renamed to --ignore-versions
.
In this release, some key changes to the core framework have been implemented. It is now possible +to have more than one communication interface for a device, which enables some interesting use +cases like partial interfaces, or multiple communication protocols accessing the same device. One +prerequisite for this feature was running the network services in different threads than the +device simulation.
+Another key change, one that requires some minor changes to existing devices (see upgrade guide), +was that the communication interface definition has been completely separate from the network +services handling the network communication.
+Besides these major improvements, there have been a number of smaller improvements and new +features, and Lewis now also has a logo (see below).
+It is now possible to have devices with more than one communication interface. The -p
-option
+can be supplied multiple times:
$ lewis some_device -p protocol1 -p protocol2
+
When no -p
option is specified, the script behaves as before (use default protocol if
+possible or produce an error message). To start a simulation without any device communication,
+use the new -n
/--no-interface
option:
$ lewis some_device -n
+
It is not possible to use both -p
and -n
at the same time, this results in an error
+message.
The epics_device
example has been renamed to dual_device
and extended to include a
+second interface definition, so it exposes the device state via two different protocols:
$ lewis -k lewis.examples dual_device -p epics -p stream
+
lewis.adapters.stream
has been extended. Besides regular expressions, it is now
+possible to use scanf
format specifications to define commands. This makes handling
+of for example floating point numbers much more convenient:
from lewis.adapters import StreamInterface, Cmd, scanf
+
+ class SomeInterface(StreamInterface):
+ commands = {
+ Cmd(lambda x: x**2, scanf('SQ %f'))
+ }
+
lewis.adapters.stream.scanf
provides argument mappings for the matched arguments
+automatically, so it is optional to pass them to Cmd
. In the case outlined above, the
+argument is automatically converted to float
.
If a string is specified directly (instead of scanf(...)
), it is treated as a regular
+expression like in earlier versions.
Internally, the scanf package is used for handling these patterns, please check the package +documentation for all available format specifiers. Thanks to @joshburnett for accepting +a small patch to the package that made the package easier to integrate into Lewis.
+The control client, lewis-control, now provides a version argument via --version
or -v
.
$ lewis-control -v
+
Lewis now has a logo. It is based on a state machine with one state that is entered and +repeated infinitely - like the simulation cycles in Lewis.
+For low-resolution images or settings with little space, there is also a simplified version.
+The logo was made using inkscape, the font used in the logo is Rubik (in the SVG itself, +the text was converted into a path, so that the font does not need to be installed for the logo +to render correctly). The two PNGs and also the SVGs are in the source repository, feel +free to include them in presentations or posters.
+Adapters now run in a different thread than the simulation itself. The consequence of this is +that slow network communication or expensive computations in the device do not influence +one another anymore. Otherwise, communication still works exactly like in previous versions.
The behavior of the framework_version
-variable for devices that was introduced in version
+1.0.3 has been modified to make it easier to convert from older versions of Lewis.
With the default options of the lewis
-command, devices that do not specify the variable
+will be loaded after logging a warning. An error message is only displayed when strict
+version checking is enabled through the new -S/--strict-versions
-flag.
The option to ignore version mismatches has been renamed to -I/--ignore-versions
. When
+that flag is specified, any device regardless of the contents of framework_version
is
+loaded, but a warning is still logged.
Specifying the framework_version
variable is still encouraged as it can contribute to
+more certainty on the user side as to whether a device can function with a certain function
+of Lewis.
Due to a change to how Adapters and Devices work together, device interfaces are not +inheriting from Adapter-classes anymore. Instead, there are dedicated Interface classes. +They are located in the same modules as the Adapters, so only small changes are necessary:
+Old:
+ from lewis.adapters.stream import StreamAdapter, Cmd
+
+ class DeviceInterface(StreamAdapter):
+ pass
+
New:
+ from lewis.adapters.stream import StreamInterface, Cmd
+
+ class DeviceInterface(StreamInterface):
+ pass
+
The same goes for EpicsAdapter
and ModbusAdapter
, which must be modified to
+EpicsInterface
and ModbusInterface
respectively.
This is a pure bug fix release that removes three problems that were overlooked in the 1.1 release.
+Version strings in framework_version
are now coerced, so that for example 1.1
becomes
+1.1.0
automatically.
Lewis does no longer hang forever when starting a network service fails.
Switching setups at runtime works again as in release 1.0.3, in 1.1. it had been disabled due +to an oversight.
After releasing 1.1.0 and 1.1.1, we decided to move to a more reproducible testing workflow that
+is operating closer to the packages that are released in the end. This only affects developers
+who work on the Lewis code base. In addition, lewis.adapters.epics
was improved a bit
+with better error messages and more reasonable PV update frequencies. The lewis-control
+server now runs in its own thread, which has made it more responsive.
StreamInterface
has been improved to support a readtimeout
attribute which is analogous
+to the ReadTimeout system variable in Protocol files. The value of readtimeout
determines how
+many milliseconds to wait for more input, once we have started receiving data for a command. Under
+normal circumstances, this timeout being triggered is an error and causes the incoming buffer to be
+flushed and a handle_error
call in the device interface. However, if the in_terminator
+attribute is empty, this timeout is treated as the command terminator instead.
readtimeout
defaults to 100 (ms).
+readtimeout = 0
disables this feature entirely.
The effective resolution is currently limited 10 ms increments due to the fixed adapter cycle rate.
+The lewis.core.control_server.ControlServer
is now running in its own thread, separate
+from the simulation. As a result, lewis-control
and the Python Control API are now much more
+responsive. This is because requests are processed asynchronously and, therefore, multiple
+requests can be processed per simulation cycle.
Error messages in the binding step of :class:PV
have been improved. It is now easier to find
+the source of common problems (missing properties, spelling errors).
PVs are only updated if the underlying value has actually changed. Changes to metadata are processed
+and logged separately. This leads to cleaner logs even at small values for poll_interval
.
Using yaml.safe_load
instead of yaml.load
as a security precaution.
The lewis.py
and lewis-control.py
files have been removed, because especially the former
+created some problems with the new package structure by interfering with the tests and docs-
+generation.
For using Lewis when it’s installed through pip, this does not change anything, but for
+development of the Lewis framework (not of devices), it is now strongly recommended to do so
+in a separate virtual environment, installing Lewis from source as an editable package. Details
+on this can be found in the updated developer_guide
.
Tests are now run with pytest instead of nose. In addition, a tox configuration has been +added for more reproducible tests with different interpreters.
+The first run may take a bit longer, since each step is run in a fresh virtual environment that tox +creates automatically.
+To run specific tests, for example to verify that building the docs works, use the -e
flag
+of tox
To see all tests that are available, including a short description, use tox -l -v
.
This is a major release because it removes Python 2 support. Any other changes are minor.
+Added pre-commit checking for formatting, flake8 and isort
Uses Black for formatting
Added a system test for checking that the overall system is functioning
Added scripts for running lewis and lewis-control without installing
This is a minor release that updates the supported versions list.
+lewis
", "lewis.adapters
", "lewis.adapters.epics
", "lewis.adapters.modbus
", "lewis.adapters.stream
", "lewis.core
", "lewis.core.adapters
", "lewis.core.approaches
", "lewis.core.control_client
", "lewis.core.control_server
", "lewis.core.devices
", "lewis.core.exceptions
", "lewis.core.logging
", "lewis.core.processor
", "lewis.core.simulation
", "lewis.core.statemachine
", "lewis.core.utils
", "lewis.devices
", "lewis.devices.chopper
", "lewis.devices.chopper.devices
", "lewis.devices.chopper.devices.bearings
", "lewis.devices.chopper.devices.device
", "lewis.devices.chopper.devices.states
", "lewis.devices.chopper.interfaces
", "lewis.devices.chopper.interfaces.epics_interface
", "lewis.devices.julabo
", "lewis.devices.julabo.devices
", "lewis.devices.julabo.devices.device
", "lewis.devices.julabo.devices.states
", "lewis.devices.julabo.interfaces
", "lewis.devices.julabo.interfaces.julabo_stream_interface_1
", "lewis.devices.julabo.interfaces.julabo_stream_interface_2
", "lewis.devices.linkam_t95
", "lewis.devices.linkam_t95.devices
", "lewis.devices.linkam_t95.devices.device
", "lewis.devices.linkam_t95.devices.states
", "lewis.devices.linkam_t95.interfaces
", "lewis.devices.linkam_t95.interfaces.stream_interface
", "lewis.examples
", "lewis.examples.dual_device
", "lewis.examples.example_motor
", "lewis.examples.modbus_device
", "lewis.examples.simple_device
", "lewis.examples.timeout_device
", "lewis.scripts
", "lewis.scripts.control
", "lewis.scripts.run
", "lewis.utils
", "lewis.utils.byte_conversions
", "lewis.utils.command_builder
", "lewis.utils.constants
", "lewis.utils.replies
", "Welcome to the Lewis documentation!", "Quickstart Guide", "Release 1.0", "Release 1.0.1", "Release 1.0.2", "Release 1.0.3", "Release 1.1", "Release 1.1.1", "Release 1.2", "Release 1.2.1", "Release 1.2.2", "Release 1.3.0", "Release 1.3.1", "Release 1.3.2", "Release 1.3.3", "Release notes", "Adapter Specifics", "Command line tools", "Remote Access to Devices", "Remote Access to Simulation Parameters", "Usage with Python"], "titleterms": {"0": [59, 60, 61, 62, 68], "1": [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71], "2": [61, 65, 66, 67, 70], "3": [62, 68, 69, 70, 71], "access": [75, 76], "ad": 4, "adapt": [6, 7, 8, 9, 11, 73], "analysi": 4, "api": [0, 57, 58, 75], "approach": 12, "bear": 25, "bug": [60, 61, 62, 63, 64, 66], "bugfix": 65, "build": 3, "byte_convers": 53, "chang": [3, 62, 65, 68, 69, 70], "checklist": 3, "chopper": [23, 24, 25, 26, 27, 28, 29], "client": [58, 75], "command": [62, 74], "command_build": 54, "commun": 75, "compat": 4, "connect": 58, "constant": 55, "control": [50, 58, 74, 75], "control_cli": 13, "control_serv": 14, "core": [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21], "cycl": 2, "detail": 2, "develop": [1, 57, 65, 68, 69, 70], "devic": [4, 15, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 75], "document": [4, 57, 66], "driven": 2, "dual_devic": 44, "environ": 77, "epic": [7, 73], "epics_interfac": 29, "exampl": [4, 43, 44, 45, 46, 47, 48, 58], "example_motor": 45, "except": 16, "face": 4, "featur": [59, 60, 61, 62, 63, 65, 67], "final": 3, "fix": [60, 61, 62, 63, 64, 66], "framework": [2, 4], "from": 77, "further": 4, "git": 3, "github": 3, "guid": [57, 58, 62, 63], "implement": 4, "improv": [60, 61, 62, 63, 65], "instal": [58, 77], "interfac": [4, 28, 29, 34, 35, 36, 41, 42, 62, 75], "interpret": 75, "julabo": [30, 31, 32, 33, 34, 35, 36], "julabo_stream_interface_1": 35, "julabo_stream_interface_2": 36, "lewi": [1, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 74], "line": [62, 74], "linkam_t95": [37, 38, 39, 40, 41, 42], "log": [4, 17], "merg": 3, "mileston": 3, "modbu": 8, "modbus_devic": 46, "more": 4, "motor": 58, "new": [60, 61, 62, 63, 65, 67], "note": [3, 57, 72], "other": [60, 61, 62, 63, 65], "packag": 3, "paramet": 76, "pip": 77, "prepar": [3, 4], "processor": 18, "pypi": 3, "python": [75, 77], "quickstart": [57, 58], "refer": 57, "releas": [3, 57, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72], "remot": [75, 76], "repli": 56, "run": [51, 58, 77], "script": [49, 50, 51], "setup": 4, "simple_devic": 47, "simul": [4, 19, 76], "sourc": 77, "specif": 73, "state": [27, 33, 40], "statemachin": [2, 20], "step": 4, "stream": [9, 73], "stream_interfac": 42, "syntax": 75, "telnet": 58, "test": [3, 4], "timeout_devic": 48, "tool": 74, "unit": 4, "updat": 3, "upgrad": [62, 63], "upload": 3, "usag": 77, "user": [4, 57], "util": [21, 52, 53, 54, 55, 56], "valu": 75, "version": [3, 4], "via": [58, 77], "virtual": 77, "welcom": 57, "write": 4}})
\ No newline at end of file
diff --git a/user_guide/adapter_specifics.html b/user_guide/adapter_specifics.html
new file mode 100644
index 00000000..186c16fa
--- /dev/null
+++ b/user_guide/adapter_specifics.html
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+ The EPICS adapter takes only one optional argument:
+prefix
: This string is prefixed to all PV names. Defaults to empty / no prefix.
Arguments meant for the adapter can be specified with the adapter options. +For example:
+$ python lewis.py chopper --adapter-options "epics: {prefix: 'SIM2:'}"
+
On Linux, this means that EPICS_CA_ADDR_LIST
must include this
+networks broadcast address:
$ export EPICS_CA_AUTO_ADDR_LIST=NO
+$ export EPICS_CA_ADDR_LIST=172.17.255.255
+$ export EPICS_CAS_INTF_ADDR_LIST=localhost
+
The TCP Stream adapter has the following optional arguments:
+bind_address
: Address of network adapter to listen on.
+Defaults to “0.0.0.0” (all network adapters).
port
: Port to listen for connections on. Defaults to 9999.
telnet_mode
: When True, overrides both in and out terminators
+to CRNL for telnet compatibility. Defaults to False.
Arguments meant for the adapter can be specified with the adapter options. +For example:
+$ python lewis.py linkam_t95 -p "stream: {bind_address: localhost, port: 1234}"
+
Please note that this functionality should only be used on a trusted +network.
+Besides the device specific protocols, the device can be made accessible
+from the outside via JSON-RPC over ZMQ. This can be achieved by passing
+the -r
option with a host:port
string to the simulation:
$ lewis chopper -r 127.0.0.1:10000 -p "epics: {prefix: 'SIM:'}"
+
Now the device can be controlled via the lewis-control.py
-script
+in a different terminal window. The service can be queried to show the
+available objects by not supplying an object name:
$ lewis-control -r 127.0.0.1:10000
+
The -r
(or --rpc-host
) option defaults to the value shown here,
+so it will be omitted in the following examples. To get information on
+the API of an object, supplying an object name without a property or
+method will list the object’s API:
$ lewis-control device
+
This will output a list of properties and methods which is available for +remote access. This may not comprise the full interface of the object +depending on the server side configuration. Obtaining the value of a +property is done like this:
+$ lewis-control device state
+
The same syntax is used to call methods without parameters:
+$ lewis-control device initialize
+
To set a property to a new value, the value has to be supplied on the +command line:
+$ lewis-control device target_speed 100
+$ lewis-control device start
+
It is possible to set multiple device parameters at once, but this goes through the simulation +itself, so that it is generic to all devices:
+$ lewis-control simulation set_device_parameters "{'target_speed': 1, 'target_phase': 20}"
+
Another case of device-related access to the simulation is switching the setup. To obtain a +list of available setups, the following command is available:
+$ lewis-control simulation setups
+
It is then possible to switch the setup to one from the list, assuming it is called new_setup
:
$ lewis-control simulation switch_setup new_setup
+
The setup switching process is logged.
+Just as device model and communication interface are separate concepts in Lewis, these interfaces +can be controlled separately as well.
+To query the available communication protocols, the following command is available:
+$ lewis-control interface protocols
+
This will list all communication protocols that are currently exposing device behavior. +The following methods are available for interacting with the communication interfaces:
+$ lewis-control interface disconnect
+$ lewis-control interface connect
+$ lewis-control interface is_connected
+$ lewis-control interface documentation
+
Without any arguments, these methods will affect all of the device’s interfaces, but specifying
+any number of valid protocol names will limit the method to those protocols. Assuming a device
+has two interfaces, one for the stream
protocol and one for epics
, the following sequence
+would disconnect both, but then only reconnect the stream
-adapter:
$ lewis-control interface disconnect
+$ lewis-control interface connect stream
+$ lewis-control interface is_connected stream
+True
+$ lewis-control interface is_connected
+{'stream': True, 'epics': False}
+
Disconnecting is essentially the equivalent of “cutting the cable”, no new connections +will be accepted and existing ones will be terminated.
+To find out how to interact with any device via its usual communication channels a way would be:
+$ lewis-control interface protocol
+['stream', 'epics']
+$ lewis-control interface documentation epics
+[ ... long description of protocol ... ]
+
lewis_control
interprets values as built-in Python literals or containers using
+ast.literal_eval
. This means any
+syntax for literal evaluation supported by Python works here as well. The following are all valid
+values which are interpreted as you might expect:
$ lewis-control device float_value 12.0
+$ lewis-control device float_value .5
+$ lewis-control device float_value 1.23e10
+$ lewis-control device int_value 123
+$ lewis-control device int_value 0xDEADBEEF
+$ lewis-control device int_value 010 # Value of 8 in base 8 (octal)
+$ lewis-control device str_value hello_world
+$ lewis-control device method_call_with_two_string_args hello world
+$ lewis-control device str_value "hello world"
+$ lewis-control device unicode_value "u'hello_world'"
+$ lewis-control device list_value "[1,2,3]"
+$ lewis-control device list_value "['a', 'b', 'c']"
+$ lewis-control device dict_value "{'a': 1, 'b': 2}"
+
WARNING: Any value that cannot be successfully evaluated is silently converted into a +string literal instead! The following attempts turn into strings because the letters +are not quoted:
+$ lewis-control device str_value_looks_like_dict "{a: 1, b: 2}"
+$ lewis-control device str_value_looks_like_list "[a, b, c]"
+
This is done for convenience, to avoid having to double quote and/or escape quote trivial string +values to match Python syntax while also taking shell quotation and escapes into account. But it +can lead to unexpected results at times.
+For use cases that require more flexibility and control, it is advised to write a Python script
+using the API provided in lewis.core.control_client
instead of using the command line utility.
+This makes it possible to use the remote objects in a fairly transparent fashion.
Here is a brief example using the chopper
device:
from time import sleep
+from lewis.core.control_client import ControlClient
+
+client = ControlClient(host='127.0.0.1', port='10000')
+chopper = client.get_object('device')
+
+chopper.target_speed = 100
+chopper.initialize()
+
+while chopper.state != 'stopped':
+ sleep(0.1)
+
+chopper.start()
+
All calls, reads and assignments are synchronous and blocking in terms of the methods and +attributes they access on the server. However, much like with real devices, the behaviour of the +simulated device is asynchronous from its interface. Consequently, depending on the specific +device, some effects of calling a method may take place long after the method is called (and +returns).
+This is why, in the above example, a loop is used to wait for chopper.state
to change in
+response to the chopper.initialize()
call.
Please note that this functionality should only be used on a trusted +network.
+Certain control over the simulation is also exposed in the shape of an
+object named simulation
if Lewis is started with -r
. The
+simulation can be paused and resumed using the control script:
$ lewis-control simulation pause
+$ lewis-control simulation resume
+
With these commands, the simulation is paused, while the communication +with the device remains responsive. The communication channel (for +example TCP stream server) would still respond to queries and commands, +but they would not be processed by the device.
+The speed of the simulation can be adjusted as well, along with the
+number of cycles that are processed per second (via the cycle_delay
+parameter).
$ lewis-control simulation speed 10
+$ lewis-control simulation cycle_delay 0.05
+
This will cause the twice as many cycles per second to be computed +compared to the default, and the simulation runs ten times faster than +actual time.
+It’s also possible to obtain some information about the simulation, for +example how long it has been running and how much simulated time has +passed:
+$ lewis-control simulation uptime
+$ lewis-control simulation runtime
+
Finally, the simulation can also be stopped:
+$ lewis-control simulation stop
+
It is not possible to recover from that, as the processing of remote +commands stops as well. The only way to restart the simulation is to run +Lewis again with the same parameters.
+To use Lewis directly via Python you must first install its dependencies:
+Python 3.6+
pip (latest)
On most linux systems these can be installed via the distribution’s package manager.
+We recommend using a virtual environment.
+Lewis is available on the Python Package Index <https://pypi.python.org/pypi/lewis>
__. That means
+it can be installed using pip:
$ pip install lewis
+
This will install lewis along with its dependencies. If you would like to use EPICS based devices +and have a working EPICS environment on your machine, you can install it like this to get the +additional required dependencies:
+$ pip install lewis[epics]
+
This will install two scripts in the path, lewis
and lewis-control
. Both scripts provide
+command line help:
$ lewis --help
+$ lewis-control --help
+
To list available devices, just type lewis
in the command line, a list of devices that are
+available for simulation will be printed.
All following sections of this user manual assume that Lewis has been installed via pip and that
+the lewis
command is available.
Clone the repository in a location of your choice, we recommend that you do it inside a virtual +environment so that you can keep track of the dependencies:
+$ git clone https://github.com/ISISComputingGroup/lewis.git
+
If you do not have git available, you can
+also download this repository as an archive and unpack it somewhere. A
+few additional dependencies must be installed. This can be done through
+pip in the top level directory of Lewis, which contains the setup.py
-file:
$ pip install .
+
Note: There are a few optional dependencies for certain adapter types. Currently the only
+optional dependency is pcaspy
for using devices with an EPICS interface, it requires a
+working installation of EPICS base. Please refer to the installation instructions <https://pcaspy.readthedocs.io/en/latest/installation.html>
__ of the module.
+To include pcaspy
in the installation of dependencies, use:
$ pip install ".[epics]"
+
If you also want to develop Lewis, the workflow is a bit different. Please refer to the
+developer_guide
for details.
If you want to use the EPICS adapter, you will also need to configure a few more +EPICS environment variables correctly. If you only want to communicate +using EPICS locally via the loopback device, you can configure it like +this:
+$ export EPICS_CA_AUTO_ADDR_LIST=NO
+$ export EPICS_CA_ADDR_LIST=localhost
+$ export EPICS_CAS_INTF_ADDR_LIST=localhost
+
Once all dependencies and requirements are satisfied, Lewis can be +run using the following general format (from inside the Lewis +directory):
+$ python -m lewis device_name [arguments]
+
You can then run Lewis as follows (from within the lewis +directory):
+$ python -m lewis chopper -p epics
+
Details about parameters for the various adapters, and differences +between OSes are covered in the “Adapter Specifics” sections.
+If you decided to install Lewis this way, please be aware that the lewis
and lewis-control
+calls in the other parts of the guide have to be replaced with python lewis.py
.
Lewis can be run directly from source. First it is necessary to install the basic requirements:
+$ pip install -r requirements.txt
+
If you would like to use EPICS based devices
+and have a working EPICS environment on your machine then it is necessary to install pcaspy
like so:
$ pip install pcaspy
+
There are Python scripts for running both lewis
and lewis-control
in the top-level scripts directory.
+These scripts work exactly the same as when Lewis is installed via pip (see above). For example:
$ python scripts/lewis.py --help
+$ python scripts/lewis-control.py --help
+