diff --git a/Dockerfile b/Dockerfile index 255ba4f..d61c8e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python #ARG img_tag=develop-dev -ARG img_tag=v5.1.0 +ARG img_tag=v5.1.1 FROM ${img_user}/${img_repo}:${img_tag} diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..ff0707b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include dragonfly *.txt diff --git a/docker-compose.yaml b/docker-compose.yaml index dfd23e9..880ba9e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -55,3 +55,21 @@ services: - /usr/local/src_dragonfly/dragonfly/watchdog.py - --config - /root/AlarmSystem.yaml + + Turbo1: + image: dragonfly_turbovac:latest + depends_on: + - rabbit-broker + volumes: + - ./examples/turbo_1.yaml:/root/devices/turbo_1.yaml + environment: + - DRIPLINE_USER=dripline + - DRIPLINE_PASSWORD=dripline + command: + - dl-serve + - -c + - /root/devices/turbo_1.yaml + - -b + - rabbit-broker + - -vvv + diff --git a/dripline/extensions/__init__.py b/dripline/extensions/__init__.py index e9cb836..9e5dc1d 100644 --- a/dripline/extensions/__init__.py +++ b/dripline/extensions/__init__.py @@ -4,6 +4,7 @@ # Subdirectories from . import jitter +from . import turbovac # Modules in this directory diff --git a/dripline/extensions/turbovac/README.md b/dripline/extensions/turbovac/README.md new file mode 100644 index 0000000..7e936b4 --- /dev/null +++ b/dripline/extensions/turbovac/README.md @@ -0,0 +1,7 @@ +The files contained in telegram are a copy from the telegram folder of +https://github.com/fkivela/TurboCtl/tree/work +I modified the files to be compatible with python versions < 3.10, by changing some of the newer syntax features to clasical implementations. +We use the TurboCtl.telegram to generate the content of the telegram but use the dripline service to send it. Thus we can not make use of any of the other implementations. + +The TurboVac Ethernet Service implements the USS protocol which consists of a STX-byte (\x02), LENGTH-byte, ADDRESS-byte, payload, XOR-checksum-byte. +The Payload is configured within the Endpoint class and makes use of the TurboCtl.telegram module. diff --git a/dripline/extensions/turbovac/__init__.py b/dripline/extensions/turbovac/__init__.py new file mode 100644 index 0000000..0d30b57 --- /dev/null +++ b/dripline/extensions/turbovac/__init__.py @@ -0,0 +1,4 @@ +__all__ = [] + +from .ethernet_uss_service import * +from .turbovac_endpoint import * diff --git a/dripline/extensions/turbovac/ethernet_uss_service.py b/dripline/extensions/turbovac/ethernet_uss_service.py new file mode 100644 index 0000000..70e6ae4 --- /dev/null +++ b/dripline/extensions/turbovac/ethernet_uss_service.py @@ -0,0 +1,143 @@ +import re +import socket +import threading + +from dripline.core import Service, ThrowReply + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + + +__all__.append('EthernetUSSService') +class EthernetUSSService(Service): + ''' + ''' + def __init__(self, + socket_timeout=1.0, + socket_info=('localhost', 1234), + **kwargs + ): + ''' + Args: + socket_timeout (int): number of seconds to wait for a reply from the device before timeout. + socket_info (tuple or string): either socket.socket.connect argument tuple, or string that + parses into one. + ''' + Service.__init__(self, **kwargs) + + if isinstance(socket_info, str): + print(f"Formatting socket_info: {socket_info}") + re_str = "\([\"'](\S+)[\"'], ?(\d+)\)" + (ip,port) = re.findall(re_str,socket_info)[0] + socket_info = (ip,int(port)) + + self.alock = threading.Lock() + self.socket = socket.socket() + self.socket_timeout = float(socket_timeout) + self.socket_info = socket_info + self.STX = b'\x02' + self._reconnect() + + def _reconnect(self): + ''' + Method establishing socket to ethernet instrument. + Called by __init__ or send (on failed communication). + ''' + # close previous connection + # open new connection + # check if connection was successful + + self.socket.close() + self.socket = socket.socket() + try: + self.socket = socket.create_connection(self.socket_info, self.socket_timeout) + except (socket.error, socket.timeout) as err: + print(f"connection {self.socket_info} refused: {err}") + raise Exception('resource_error_connection', f"Unable to establish ethernet socket {self.socket_info}") + print(f"Ethernet socket {self.socket_info} established") + + def send_to_device(self, commands, **kwargs): + ''' + Standard device access method to communicate with instrument. + NEVER RENAME THIS METHOD! + + commands (list||None): list of command(s) to send to the instrument following (re)connection to the instrument, still must return a reply! + : if impossible, set as None to skip + ''' + if not isinstance(commands, list): + commands = [commands] + + self.alock.acquire() + + try: + data = self._send_commands(commands) + except socket.error as err: + print(f"socket.error <{err}> received, attempting reconnect") + self._reconnect() + data = self._send_commands(commands) + print("Ethernet connection reestablished") + # exceptions.DriplineHardwareResponselessError + except Exception as err: + print(str(err)) + try: + self._reconnect() + data = self._send_commands(commands) + print("Query successful after ethernet connection recovered") + except socket.error: # simply trying to make it possible to catch the error below + print("Ethernet reconnect failed, dead socket") + raise Exception('resource_error_connection', "Broken ethernet socket") + except Exception as err: ##TODO handle all exceptions, that seems questionable + print("Query failed after successful ethernet socket reconnect") + raise Exception('resource_error_no_response', str(err)) + finally: + self.alock.release() + to_return = b''.join(data) + print(f"should return:\n{to_return}") + return to_return + + def _send_commands(self, commands): + ''' + Take a list of telegrams, send to instrument and receive responses, do any necessary formatting. + + commands (list||None): list of command(s) to send to the instrument following (re)connection to the instrument, still must return a reply! + : if impossible, set as None to skip + ''' + all_data=[] + + for command in commands: + if not isinstance(command, bytes): + raise ValueError("Command is not of type bytes: {command}, {type(command)}") + print(f"sending: {command}") + self.socket.send(command) + data = self._listen() + print(f"sync: {repr(command)} -> {repr(data)}") + all_data.append(data) + return all_data + + def _listen(self): + ''' + Query socket for response. + ''' + data = b'' + length = None + try: + while True: + tmp = self.socket.recv(1024) + data += tmp + if self.STX in data: + start_idx = data.find(self.STX) + data = data[start_idx:] # get rid of everything before the start + if len(data)>1: # if >1 data we have a length info + length = int(data[1])+2 + if len(data) >= length: # if we are >= LENGTH we have all we need + break + if tmp == '': + raise Exception('resource_error_no_response', "Empty socket.recv packet") + except socket.timeout: + print(f"socket.timeout condition met; received:\n{repr(data)}") + raise Exception('resource_error_no_response', "Timeout while waiting for a response from the instrument") + print(repr(data)) + data = data[0:length] + return data diff --git a/dripline/extensions/turbovac/telegram/__init__.py b/dripline/extensions/turbovac/telegram/__init__.py new file mode 100755 index 0000000..44f4127 --- /dev/null +++ b/dripline/extensions/turbovac/telegram/__init__.py @@ -0,0 +1,3 @@ +"""This package is used to form telegrams that send commands to the +pump. +""" \ No newline at end of file diff --git a/dripline/extensions/turbovac/telegram/api.py b/dripline/extensions/turbovac/telegram/api.py new file mode 100755 index 0000000..5a3e7e8 --- /dev/null +++ b/dripline/extensions/turbovac/telegram/api.py @@ -0,0 +1,127 @@ +"""This module defines an API of functions which can be used to send commands +to the pump. + +All functions in this module share the following common arguments: + + *connection*: + This is a :class:`serial.Serial` instance, which is used to send the + command. + + *pump_on*: + If this evaluates to ``True``, control bits telling the pump to turn or + stay on are added to the telegram. Otherwise receiving the telegram + will cause the pump to turn off if it is on. + +If a command cannot be sent due to an error in the connection, a +:class:`serial.SerialException` will be raised. + +The functions return both the query sent to the pump and the reply received +back as :class:`~turboctl.telegram.telegram.TelegramReader` instances. +""" + +from .codes import ControlBits +from .telegram import (Telegram, TelegramBuilder, + TelegramReader) + + +_PUMP_ON_BITS = [ControlBits.COMMAND, ControlBits.ON] +_PUMP_OFF_BITS = [ControlBits.COMMAND] + + +def send(connection, telegram): + """Send *telegram* to the pump. + + Args: + telegram: A :class:`~turboctl.telegram.telegram.Telegram` instance. + """ + connection.write(bytes(telegram)) + reply_bytes = connection.read(Telegram.LENGTH) + reply = TelegramBuilder().from_bytes(reply_bytes).build() + return TelegramReader(telegram, 'query'), TelegramReader(reply, 'reply') + + +def status(connection, pump_on=None): + """Request pump status. + + This function sends an empty telegram to the pump, which causes it to send + back a reply containing some data about the status of the pump. + + This can also be used for turning the pump on or off by setting *pump_on* + to ``True`` or ``False``. + """ + builder = TelegramBuilder() + if pump_on: + builder.set_flag_bits(_PUMP_ON_BITS) + else: + builder.set_flag_bits(_PUMP_OFF_BITS) + + query = builder.build() + return send(connection, query) + + +def reset_error(connection): + """Reset the error status of the pump. + + This function sends a "reset error" command to the pump. + """ + builder = TelegramBuilder() + clear_error = [ControlBits.COMMAND, ControlBits.RESET_ERROR] + builder.set_flag_bits(clear_error) + query = builder.build() + return send(connection, query) + + +def _access_parameter(connection, mode, number, value, index, pump_on): + """This auxiliary function provides functionality for both reading and + writing parameter values, since the processes are very similar. + """ + builder = (TelegramBuilder() + .set_parameter_mode(mode) + .set_parameter_number(number) + .set_parameter_index(index) + .set_parameter_value(value) + ) + + if pump_on: + builder.set_flag_bits(_PUMP_ON_BITS) + + query = builder.build() + return send(connection, query) + + +def read_parameter(connection, number, index=0, pump_on=True): + """Read the value of an index of a parameter. + + Args: + number + The number of the parameter. + + index + The index of the parameter (0 for unindexed parameters). + + Raises: + ValueError: + If *number* or *index* have invalid values. + """ + return _access_parameter(connection, 'read', number, 0, index, pump_on) + + +def write_parameter(connection, number, value, index=0, pump_on=True): + """Write a value to an index of a parameter. + + Args: + number: + The number of the parameter. + + value: + The value to be written. + + index: + The index of the parameter (0 for unindexed parameters). + + Raises: + ValueError: + If *number* or *index* have invalid values. + """ + return _access_parameter( + connection, 'write', number, value, index, pump_on) diff --git a/dripline/extensions/turbovac/telegram/codes.py b/dripline/extensions/turbovac/telegram/codes.py new file mode 100755 index 0000000..ded942b --- /dev/null +++ b/dripline/extensions/turbovac/telegram/codes.py @@ -0,0 +1,508 @@ +"""This module defines enums for the different codes and numbers used +in TURBOVAC telegrams. + +Members of enums can be accessed with any of the following syntaxes: + + >>> member = EnumName.MEMBER_NAME + >>> member = EnumName['MEMBER_NAME'] + >>> member = EnumName(member_value) + >>> member = EnumName(member) +""" + +import enum as e + + +### Parameter access and response codes ### + +class ParameterCode(e.Enum): + """A superclass for parameter access and response codes. + + A telegram that tells the pump to accesses a parameter must contain an + access code, which depends on the type of the parameter and the access + mode. + The reply telegram sent by the pump then contains a response code which + also depends on the parameter and the access mode, and also tells whether + the access was successful. + + This enum doesn't have any members, since it's only meant to + be subclassed. Members of subclasses have the following fields: + + **value** + The code (a 4-character :class:`str`). + + **mode** + A string that groups the codes together by function + (e.g. ``read`` for all read modes). + + **indexed** + ``True`` if this mode can be only used for indexed parameters, + ``False`` if it can only be used for unindexed parameters, + and ``...`` if it can be used for both. + + **bits** + ``16`` or ``32`` if the mode can only be used for 16 or 32 bit + parameters, and ``...`` if it can be used for both. + + **description** + A verbal description of the meaning of the code. + """ + + def __new__(cls, value, mode, indexed, bits, description): + # __new__ is defined instead of __init__, because setting + # _value_ in __init__ prevents the syntax + # 'member = EnumName(value_of_member)' from working. + # This is a comment instead of a docstring to prevent this from + # showing up in the docs. + obj = object.__new__(cls) + obj._value_ = value + obj.mode = mode + obj.indexed = indexed + obj.bits = bits + obj.description = description + return obj + + # This doesn't show up in the docs for some reason, but doesn't + # necessarily need to. + def __repr__(self): + """repr(self) returns '' by default. + It must be overridden so that the syntax + "copy = eval(repr(x))" + works. + """ + return str(self) + + +class ParameterAccess(ParameterCode): + """Different parameter access modes. + + This enum contains the following members: + + =========== ========== =========== ========= ======= ======================== + Member name Value Mode Indexed Bits Description + =========== ========== =========== ========= ======= ======================== + ``NONE`` ``'0000'`` ``'none'`` ``...`` ``...`` ``'No access'`` + ``R`` ``'0001'`` ``'read'`` ``False`` ``...`` ``'Read a value'`` + ``W16`` ``'0010'`` ``'write'`` ``False`` ``16`` ``'Write a 16 bit value'`` + ``W32`` ``'0011'`` ``'write'`` ``False`` ``32`` ``'Write a 32 bit value'`` + ``RF`` ``'0110'`` ``'read'`` ``True`` ``...`` ``'Read a field value'`` + ``W16F`` ``'0111'`` ``'write'`` ``True`` ``16`` ``'Write a 16 bit field value'`` + ``W32F`` ``'1000'`` ``'write'`` ``True`` ``32`` ``'Write a 32 bit field value'`` + =========== ========== =========== ========= ======= ======================== + """ + # Sphinx doesn't work well with enums, so the members have to be documented + # by hand. + # These tables are allowed to exceed the maximum line length, since + # breaking the lines would seriously hurt readability. + +# Name Value Mode Indexed Bits Description + NONE = ('0000', 'none', ..., ..., 'No access') + R = ('0001', 'read', False, ..., 'Read a value') + W16 = ('0010', 'write', False, 16, 'Write a 16 bit value') + W32 = ('0011', 'write', False, 32, 'Write a 32 bit value') + RF = ('0110', 'read', True, ..., 'Read a field value') + W16F = ('0111', 'write', True, 16, 'Write a 16 bit field value') + W32F = ('1000', 'write', True, 32, 'Write a 32 bit field value') + + +class ParameterResponse(ParameterCode): + """Different parameter response modes. + + This enum contains the following members: + + ============ ========== ============== ========= ======= ============================= + Member name Value Mode Indexed Bits Description + ============ ========== ============== ========= ======= ============================= + ``NONE`` ``'0000'`` ``'none'`` ``...`` ``...`` ``'No response'`` + ``S16`` ``'0001'`` ``'response'`` ``False`` ``16`` ``'16 bit value sent'`` + ``S32`` ``'0010'`` ``'response'`` ``False`` ``32`` ``'32 bit value sent'`` + ``S16F`` ``'0100'`` ``'response'`` ``False`` ``16`` ``'16 bit field value sent'`` + ``S32F`` ``'0101'`` ``'response'`` ``True`` ``32`` ``'32 bit field value sent'`` + ``ERROR`` ``'0111'`` ``'error'`` ``True`` ``...`` ``'Cannot run command'`` + ``NO_WRITE`` ``'1000'`` ``'no write'`` ``True`` ``...`` ``'No write access'`` + ============ ========== ============== ========= ======= ============================= + """ + +# Name Value Mode Indexed Bits Description + NONE = ('0000', 'none', ..., ..., 'No response') + S16 = ('0001', 'response', False, 16, '16 bit value sent') + S32 = ('0010', 'response', False, 32, '32 bit value sent') + S16F = ('0100', 'response', True, 16, '16 bit field value sent') + S32F = ('0101', 'response', True, 32, '32 bit field value sent') + ERROR = ('0111', 'error', ..., ..., 'Cannot run command') + NO_WRITE = ('1000', 'no write', ..., ..., 'No write access') + + +def get_parameter_code(telegram_type, mode, indexed, bits): + """Return the parameter code (as an enum member) that matches the + arguments. + + *telegram_type* is ``'query'`` for messages to the pump and ``'reply'`` for + messages from the pump. + + Raises: + :class:`ValueError`: If the number of matching members isn't 1, + or if *telegram_type* is invalid. + """ + + if telegram_type == 'query': + enum = ParameterAccess + elif telegram_type == 'reply': + enum = ParameterResponse + else: + raise ValueError(f'invalid telegram_type: {telegram_type}') + + results = [] + + for member in enum: + mode_match = member.mode == mode + index_match = member.indexed in [indexed, ...] + bits_match = member.bits in [bits, ...] + + if mode_match and index_match and bits_match: + results.append(member) + + if len(results) == 0: + raise ValueError('no matching codes') + + if len(results) > 1: + raise ValueError('several matching codes') + + return results[0] + + +def get_parameter_mode(telegram_type, code): + """Return the parameter mode that matches the arguments. + + *telegram_type* is ``'query'`` for messages to the pump and ``'reply'`` for + messages from the pump. + """ + + if telegram_type == 'query': + enum = ParameterAccess + elif telegram_type == 'reply': + enum = ParameterResponse + else: + raise ValueError(f'invalid telegram_type: {telegram_type}') + + member = enum(code) + return member.mode + + +### Parameter errors ### + +class ParameterExceptionMeta(type): + """A metaclass for parameter exceptions. + + The instances of this metaclass are different types of parameter + exceptions, which all have the :attr:`member` property. + Because these instances are error classes instead of instances of error + classes, the correct syntax to access this property is + + :: + + ParameterErrorX.member + + instead of + + :: + + ParameterErrorX().member + + This metaclass exists, because :class:`ParameterException` subclasses and + :class:`ParameterError` members which represent the same error need to + contain references to each other. + Direct references would create circular dependencies which would prevent + the objects from being initialized. + The easiest way to circumvent this problem is to define the + :attr:`member` attribute as a property that is computed at runtime. + Python doesn't have built-in class properties, so the easiest way to give + exception classes a property is to use a metaclass. + """ + + @property + def member(self): + """Return the :class:`ParameterException` member which has *self* as + its :class:`description ` attribute. + + Raises: + AttributeError: If *self* doesn't match exactly one + :class:`ParameterError` member. + """ + results = [] + for member in ParameterError: + if member.exception == self: + results.append(member) + + if len(results) != 1: + raise AttributeError(f'error class matches {len(results)} members') + + return results[0] + + +class ParameterException(Exception, metaclass=ParameterExceptionMeta): + """A superclass for exceptions that represent different error conditions + which are raised when the pump cannot access a parameter. + + This class uses the :class:`ParameterExceptionMeta` metaclass, so its + subclasses have the :attr:`~ParameterExceptionMeta.member` attribute. + However, this superclass itself doesn't represent any specific error, so + trying to access :attr:`ParameterException.member + ` raises an :class:`AttributeError`. + """ + + @property + def member(self): + """Return the :attr:`~ParameterExceptionMeta.member` attribute of the + class of which *self* is an instance. + + This property is defined in order for the syntax + + :: + + ParameterErrorX().member + + to work alongside + + :: + + ParameterErrorX.member + + This makes it possible to write try-catch blocks like the following: + + :: + + try: + # Do something. + except ParameterException as error: + member = error.member + # This would have to be "type(error).member" without this + # property. + + # Do something with *member*. + """ + return type(self).member + + +class WrongNumError(ParameterException): + """Raised when trying to access a parameter with a number which doesn't + exist. + """ + + +class CannotChangeError(ParameterException): + """Raised when a parameter cannot be changed.""" + + +class MinMaxError(ParameterException): + """Raised when a value cannot be assigned to a parameter because it is + outside the range of accepted values. + """ + + +class ParameterIndexError(ParameterException): + """Raised when trying to access a non-existent index of a parameter.""" + + +class AccessError(ParameterException): + """Raised when the access code doesn't match the parameter the pump tries + to access. + """ + + +class OtherError(ParameterException): + """Raised for other parameter error conditions.""" + + +class SavingError(ParameterException): + """Raised when trying to access a parameter which is simultaneously being + saved to nonvolatile memory. + """ + + +class CustomInt(int): + """A custom superclass for enums that inherit :class:`int` while having + members with multiple fields. + """ + + def __new__(cls, value, *args, **kwargs): + """The arguments of ``__new__`` methods in enums inheriting + :class:`int` are always passed to :meth:`int.__new__`, + which often raises an error if there are multiple arguments present. + This class solves the problem by only passing the first argument to + :meth:`int.__new__`. + """ + return int.__new__(cls, value) + + +class ParameterError(CustomInt, e.Enum): + """Different parameter errors. + + This class also inherits :class:`CustomInt`, which means its members can + e.g. be easily ordered. + + Members of this enum have the following fields: + **value** + The number of the error (:class:`int`). + **description** + A short :class:`str` describing of the meaning of the error. + **exception** + The :class:`ParameterException` subclass that corresponds to this + member. + + This enum contains the following members: + + ================= ======= ==================================================== ============================ + Member name Value Description Exception + ================= ======= ==================================================== ============================ + ``WRONG_NUM`` ``0`` ``'Invalid parameter number'`` :class:`WrongNumError` + ``CANNOT_CHANGE`` ``1`` ``'Parameter cannot be changed'`` :class:`CannotChangeError` + ``MINMAX`` ``2`` ``'Min/max error'`` :class:`MinMaxError` + ``INDEX`` ``3`` ``'Index error'`` :class:`ParameterIndexError` + ``ACCESS`` ``5`` ``'Access mode doesn't match parameter'`` :class:`AccessError` + ``OTHER`` ``18`` ``'Other error'`` :class:`OtherError` + ``SAVING`` ``102`` ``'Parameter is being saved to nonvolatile memory'`` :class:`SavingError` + ================= ======= ==================================================== ============================ + """ + + def __init__(self, value, description, exception): + self._value_ = value + self.description = description + self.exception = exception + + WRONG_NUM = ( 0, 'Invalid parameter number', WrongNumError) + CANNOT_CHANGE = ( 1, 'Parameter cannot be changed', CannotChangeError) + MINMAX = ( 2, 'Min/max error', MinMaxError) + INDEX = ( 3, 'Index error', ParameterIndexError) + ACCESS = ( 5, "Access mode doesn't match " + "parameter", AccessError) + OTHER = ( 18, 'Other error', OtherError) + SAVING = (102, 'Parameter is being saved to ' + 'nonvolatile memory', SavingError) + # Error codes 3, 5 and 102 aren't included in the manual, but were + # discovered while testing the pump. + + +### Control and status bits ### + +class FlagBits(CustomInt, e.Enum): + """A superclass for control and status bits. + + This class is otherwise similar to :class:`ParameterError`, but it doesn't + have any members since it's only meant to be subclassed, + and the members of its subclasses lack the **exception** field. + """ + + def __init__(self, value, description): + self._value_ = value + self.description = description + + +class ControlBits(FlagBits): + """Different control bits. + + Unused control bits have been assigned values in order to + prevent errors in the program. + + This class inherits :class:`FlagBits`, but for some reason Sphinx doesn't + show that properly. + + This enum contains the following members: + + =============== ====== ================================== + Member name Value Description + =============== ====== ================================== + ``ON`` ``0`` ``'Turn or keep the pump on'`` + ``UNUSED1`` ``1`` ``'Unknown control bit: 1'`` + ``UNUSED2`` ``2`` ``'Unknown control bit: 2'`` + ``UNUSED3`` ``3`` ``'Unknown control bit: 3'`` + ``UNUSED4`` ``4`` ``'Unknown control bit: 4'`` + ``X201`` ``5`` ``'Output X201 (air cooling)'`` + ``SETPOINT`` ``6`` ``'Enable frequency setpoint'`` + ``RESET_ERROR`` ``7`` ``'Reset error (all components)'`` + ``STANDBY`` ``8`` ``'Enable standby'`` + ``UNUSED9`` ``9`` ``'Unknown control bit: 9'`` + ``COMMAND`` ``10`` ``'Enable control bits'`` + ``X1_ERROR`` ``11`` ``'Error operation relay X1'`` + ``X1_WARNING`` ``12`` ``'Normal operation relay X1'`` + ``X1_NORMAL`` ``13`` ``'Warning relay X1'`` + ``X202`` ``14`` ``'Output X202 (packing pump)'`` + ``X203`` ``15`` ``'Output X203 (venting valve)'`` + =============== ====== ================================== + """ + # Some weird interaction of Enum, inheriting from int, and redefining + # __new__ in FlagBits prevents Sphinx from displaying the inheritance + # properly (it shows "Bases: .FlagBits"), so :show-inheritance: + # isn't used. + ON = ( 0, 'Turn or keep the pump on') + """Lorem ipsum.""" + + UNUSED1 = ( 1, 'Unknown control bit: 1') + """Dolor sit amet.""" + + UNUSED2 = ( 2, 'Unknown control bit: 2') + UNUSED3 = ( 3, 'Unknown control bit: 3') + UNUSED4 = ( 4, 'Unknown control bit: 4') + X201 = ( 5, 'Output X201 (air cooling)') + SETPOINT = ( 6, 'Enable frequency setpoint') + RESET_ERROR = ( 7, 'Reset error (all components)') + STANDBY = ( 8, 'Enable standby') + UNUSED9 = ( 9, 'Unknown control bit: 9') + COMMAND = (10, 'Enable control bits') + X1_ERROR = (11, 'Error operation relay X1') + X1_WARNING = (12, 'Normal operation relay X1') + X1_NORMAL = (13, 'Warning relay X1') + X202 = (14, 'Output X202 (packing pump)') + X203 = (15, 'Output X203 (venting valve)') + # According to the manual, bit 10 enables bits + # 0, 5, 6, 7, 8, 13, 14 and 15. Either bits 11 and 12 don't need + # to be enabled, or the manual is wrong. + + +class StatusBits(FlagBits): + """Different status bits. + + Unused status bits have been assigned values in order to + prevent errors in the program. + + This class inherits :class:`FlagBits`, but for some reason Sphinx doesn't + show that properly. + + This enum contains the following members: + + =================== ====== ====================================== + Member name Value Description + =================== ====== ====================================== + ``READY`` ``0`` ``'Ready for operation'`` + ``UNUSED1`` ``1`` ``'Unknown status bit: 1'`` + ``OPERATION`` ``2`` ``'Operation enabled'`` + ``ERROR`` ``3`` ``'Error condition (all components)'`` + ``ACCELERATION`` ``4`` ``'Accelerating'`` + ``DECELERATION`` ``5`` ``'Decelerating'`` + ``SWITCH_ON_LOCK`` ``6`` ``'Switch-on lock'`` + ``TEMP_WARNING`` ``7`` ``'Temperature warning'`` + ``UNUSED8`` ``8`` ``'Unknown status bit: 8'`` + ``PARAM_CHANNEL`` ``9`` ``'Parameter channel enabled'`` + ``DETAINED`` ``10`` ``'Normal operation detained'`` + ``TURNING`` ``11`` ``'Pump is turning'`` + ``UNUSED12`` ``12`` ``'Unknown status bit: 12'`` + ``OVERLOAD`` ``13`` ``'Overload warning'`` + ``WARNING`` ``14`` ``'Collective warning'`` + ``PROCESS_CHANNEL`` ``15`` ``'Process channel enabled'`` + =================== ====== ====================================== + """ + READY = ( 0, 'Ready for operation') + UNUSED1 = ( 1, 'Unknown status bit: 1') + OPERATION = ( 2, 'Operation enabled') + ERROR = ( 3, 'Error condition (all components)') + ACCELERATION = ( 4, 'Accelerating') + DECELERATION = ( 5, 'Decelerating') + SWITCH_ON_LOCK = ( 6, 'Switch-on lock') + TEMP_WARNING = ( 7, 'Temperature warning') + UNUSED8 = ( 8, 'Unknown status bit: 8') + PARAM_CHANNEL = ( 9, 'Parameter channel enabled') + DETAINED = (10, 'Normal operation detained') + TURNING = (11, 'Pump is turning') + UNUSED12 = (12, 'Unknown status bit: 12') + OVERLOAD = (13, 'Overload warning') + WARNING = (14, 'Collective warning') + PROCESS_CHANNEL = (15, 'Process channel enabled') \ No newline at end of file diff --git a/dripline/extensions/turbovac/telegram/datatypes.py b/dripline/extensions/turbovac/telegram/datatypes.py new file mode 100755 index 0000000..3b8a1f6 --- /dev/null +++ b/dripline/extensions/turbovac/telegram/datatypes.py @@ -0,0 +1,576 @@ +"""This module defines classes representing different data types used +in telegrams. + +.. + Aliases for Sphinx. + +.. |value| replace:: :attr:`value ` +.. |bits| replace:: :attr:`bits ` +.. |n_bytes| replace:: :attr:`n_bytes ` +.. |BYTESIZE| replace:: :const:`BYTESIZE` +""" +import math +import re +import struct + + +BYTESIZE = 8 +"""The number of bits in a byte.""" + + +def maxuint(bits): + """Return the largest unsigned integer that can be expressed with + *bits* bits. + + If *bits* is ``0``, ``0`` is returned. + + Args: + bits: + A non-negative :class:`int`. + """ + return 2**bits - 1 + + +def maxsint(bits): + """Return the largest (i.e. most positive) signed integer that can + be expressed with *bits* bits. + + If *bits* is ``0``, ``0`` is returned. + + Args: + bits: + A non-negative :class:`int`. + """ + if bits == 0: + return 0 + + return 2**(bits - 1) - 1 + + +def minsint(bits): + """Return the smallest (i.e. most negative) signed integer that can + be expressed with *bits* bits. + + If *bits* is ``0``, ``0`` is returned. + + Args: + bits: + A non-negative :class:`int`. + """ + if bits == 0: + return 0 + + return -2**(bits - 1) + + +class Data(): + """A superclass for all telegram data types. + + This class is not meant to be initialized, since it lacks an + ``__init__`` method. + """ + + @property + def value(self): + """The value represented by this data object. + This is a read-only property. + """ + return self._value + + @property + def bits(self) -> int: + """How many bits of data this object represents. + This is a read-only property. + """ + return self._bits + + @property + def n_bytes(self) -> int: + """How many bytes are needed to store the data in the object; + equal to |bits| divided by |BYTESIZE| and rounded up. + """ + return math.ceil(self.bits / BYTESIZE) + + def __add__(self, other): + """Return *self* + *other*. + + Appends the binary data in *other* to the end of *self* and + returns an object of the same class as *self* with the + combined binary data. + + Example: ``Bin('010') + Bin('001') = Bin('010001')`` + """ + class_ = type(self) + string1 = Bin(self).value + string2 = Bin(other).value + return class_(Bin(string1 + string2)) + + def __eq__(self, other): + """Return ``self == other``. + + Returns ``True``, if *self* and *other* have the same type, + |value| and |bits|, otherwise ``False``. + Two objects with a value of ``NaN`` are equal, if their types + and bits match. + """ + if type(self) != type(other): + return False + + if self.bits != other.bits: + return False + + equal_values = self.value == other.value + + try: + both_nans = math.isnan(self.value) and math.isnan(other.value) + except TypeError: + both_nans = False + + return equal_values or both_nans + + def __repr__(self): + """Return ``repr(self)``. + + The returned string uses the format + ``'ClassName(, bits=).'`` + """ + class_ = type(self).__name__ + value = repr(self.value) + bits = self.bits + return f'{class_}({value}, bits={bits})' + + def __getitem__(self, key): + """Return ``self[key]``. + + This method uses *key* to get an index or a slice + of the binary data in *self*, + and returns an object of the same class as *self* containing + the binary data in the index or slice. + + Example: + >>> Bin('010001')[0:3] + Bin('010', bits=3) + """ + class_ = type(self) + string = Bin(self).value[key] + return class_(Bin(string)) + + +class Uint(Data): + """A data type for unsigned integers.""" + + def __init__(self, value, bits=BYTESIZE): + """Initialize a new :class:`Uint`. + + This method may be called with any of the following signatures: + + :: + + Sint(value: int, bits: int=8) + Sint(value: Data) + Sint(value: bytes) + + If *value* is an :class:`int`, |value| and |bits| will be set + to the values given as arguments. + + If *value* is an instance of a subclass of :class:`Data`, + the initialized object will represent exactly the same binary + data as *value* and have the same |bits|. + + If *value* is a :class:`bytes` object, the initialized + object will represent the same binary data as *value*. + Because :class:`bytes` objects represent sequences of full + bytes, |bits| will be |BYTESIZE| multiplied by ``len(value)``. + + Raises: + TypeError or ValueError: + If *value* or *bits* have invalid types or values. + TypeError: + If the *bits* argument is supplied when *value* is + not an :class:`int`. + """ + if isinstance(value, int): + self._from_int(value, bits) + elif isinstance(value, Data): + self._from_data(value) + elif isinstance(value, bytes): + self._from_bytes(value) + else: + raise TypeError(f'invalid type for *value*: {type(value)}') + + def _from_int(self, value, bits=BYTESIZE): + _check_uint(value, bits) + self._value = value + self._bits = bits + + def _from_data(self, value): + bytes_ = bytes(value) + i = int.from_bytes(bytes_, 'big') + bits = value.bits + self._from_int(i, bits) + + def _from_bytes(self, value): + i = int.from_bytes(value, 'big') + bits = len(value) * BYTESIZE + self._from_int(i, bits) + + def __bytes__(self): + """Return ``bytes(self)``. + + Returns the binary data represented by this object as a + :class:`bytes` object with a length of |n_bytes|. + """ + return self.value.to_bytes(self.n_bytes, 'big') + + +class Sint(Data): + """A data type for signed integers formed with the + `two's complement + `_ method. + """ + + def __init__(self, value, bits=BYTESIZE): + """Initialize a new :class:`Sint`. + + This method works like :meth:`Uint.__init__`, + except that the range of valid :class:`int` values it + accepts is different. + """ + if isinstance(value, int): + self._from_int(value, bits) + elif isinstance(value, Data): + self._from_data(value) + elif isinstance(value, bytes): + self._from_bytes(value) + else: + raise TypeError(f'invalid type for *value*: {type(value)}') + + def _from_int(self, value, bits=BYTESIZE): + _check_sint(value, bits) + self._value = value + self._bits = bits + + def _from_data(self, value): + bytes_ = bytes(value) + # int.from_bytes(bytes_, 'big', signed=True) + # only works if bits == BYTESIZE. + i = int.from_bytes(bytes_, 'big') + bits = value.bits + + # Convert i to a signed integer. + if i > maxsint(bits): + i -= 2**bits + + self._from_int(i, bits) + + def _from_bytes(self, value): + i = int.from_bytes(value, 'big', signed=True) + bits = len(value) * BYTESIZE + self._from_int(i, bits) + + def __bytes__(self): + """Return ``bytes(self)``. + + See :meth:`Uint.__bytes__` for details. + """ + # Convert self.value to an unsigned integer. + i = self.value + if i < 0: + i += 2**self.bits + + # self.value.to_bytes(self.n_bytes, 'big', signed=True) + # only works if self.bits == BYTESIZE. + return i.to_bytes(self.n_bytes, 'big') + + +class Float(Data): + """A data type for + `IEEE 754 single-precision + `_ floating point numbers. + """ + + def __init__(self, value, bits=4*BYTESIZE): + """Initialize a new :class:`Float`. + + This method may be called with any of the following signatures: + + :: + + Float(value: float, bits: int=32) + Float(value: int, bits: int=32) + Float(value: Data) + Float(value: bytes) + + The method works like :meth:`Uint.__init__`, + with the following exceptions: + + - If *value* is given as a number, it can be either a :class:`float` + or an :class:`int`. :class:`int` values are automatically converted + into a corresponding :class:`float` value. + - *bits* must always be ``32``; it exists as an argument + only to give all ``__init__`` methods of :class:`Data` + subclasses a similar signature. + Likewise, if *value* is a :class:`Data` or a + :class:`bytes` object, it must contain exactly 32 + bits of data. + + Raises: + ValueError: + If *bits* is not ``32``, or *value* can't be expressed in + 32 bits of data. + """ + if isinstance(value, float): + self._from_float(value, bits) + elif isinstance(value, int): + self._from_int(value, bits) + elif isinstance(value, Data): + self._from_data(value) + elif isinstance(value, bytes): + self._from_bytes(value) + else: + raise TypeError(f'invalid type for *value*: {type(value)}') + + def _from_float(self, value, bits=4*BYTESIZE): + _check_float(value, bits) + # Values that are too close to 0 to be expressed as a float are + # rounded to 0. + self._value = struct.unpack('>f', struct.pack('>f', value))[0] + self._bits = bits + + # This is needed so that that datatype(0) works for all datatypes. + def _from_int(self, value, bits=4*BYTESIZE): + x = float(value) + self._from_float(x, bits) + + def _from_data(self, value): + bytes_ = bytes(value) + x = struct.unpack('>f', bytes_)[0] + bits = value.bits + self._from_float(x, bits) + + def _from_bytes(self, value): + x = struct.unpack('>f', value)[0] + bits = len(value) * BYTESIZE + self._from_float(x, bits) + + def __bytes__(self): + """Return ``bytes(self)``. + + See :meth:`Uint.__bytes__` for details. + """ + return struct.pack('>f', self.value) + + def __add__(self, other): + """Return *self* + *other*. + + This method works like :meth:`Data.__add__`, except that the + returned object is a :class:`Bin` instead of a :class:`Float`, + because :class:`Float` objects cannot have more than 32 bits. + """ + string1 = Bin(self).value + string2 = Bin(other).value + return Bin(string1 + string2) + + + def __getitem__(self, key): + """Return ``self[key]``. + + This method works like :meth:`Data.__getitem__`, + except that the returned object is a :class:`Bin` instead of a + :class:`Float`, + because :class:`Float` objects cannot have less than 32 bits. + """ + return Bin(self)[key] + + +class Bin(Data): + + def __init__(self, value, bits=None): + """Initialize a new :class:`Bin`. + + This method may be called with any of the following signatures: + + :: + + Bin(value: str, bits: Optional[int]=None) + Bin(value: int, bits: int=8) + Bin(value: Data) + Bin(value: bytes) + + The method works like :meth:`Uint.__init__`, with the following + exceptions: + + - If *value* is specified directly, it can be either a :class:`str` + or an :class:`int`. If it is a :class:`str`, it must be composed + solely of the characters ``'1'`` and ``'0'``, or be an empty string. + If it is an :class:`int`, it must be a valid *bits* bit unsigned + integer, which will be automatically converted into its binary + representation. + + - If *value* is a :class:`str` and *bits* is ``None``, *bits* will be + set to the length of *value*. If *value* is a :class:`str` and + *bits* is not ``None``, *value* is padded with zeroes to a length of + *bits*. Giving *bits* a value that is smaller than the length of + *value* will raise a :class:`ValueError`. + """ + if isinstance(value, str): + self._from_str(value, bits) + elif isinstance(value, int): + bits = bits if bits is not None else BYTESIZE + self._from_int(value, bits) + elif isinstance(value, Data): + self._from_data(value) + elif isinstance(value, bytes): + self._from_bytes(value) + else: + raise TypeError(f'invalid type for *value*: {type(value)}') + + def _from_str(self, value, bits=None): + _check_bin(value, bits) + self._value = value + if bits == None: + bits = len(self.value) + self._bits = bits + + # This is needed so that that datatype(0) works for all datatypes. + def _from_int(self, value, bits=BYTESIZE): + s = _bin_str(value, bits) + self._from_str(s, bits) + + def _from_data(self, value): + bytes_ = bytes(value) + i = int.from_bytes(bytes_, 'big') + bits = value.bits + s = _bin_str(i, bits) + self._from_str(s, bits) + + def _from_bytes(self, value): + i = int.from_bytes(value, 'big') + bits = len(value) * BYTESIZE + s = _bin_str(i, bits) + self._from_str(s, bits) + + def __bytes__(self): + """Return ``bytes(self)``. + + See :meth:`Uint.__bytes__` for details. + """ + if not self.value: + return b'' + + return int(self.value, 2).to_bytes(self.n_bytes, 'big') + + +def _check_uint(value, bits=None): + """Make sure *value* is a valid *bits* bit unsigned integer. + + If *bits* is None, *value* can have any number of bits. + + Raises: + TypeError or ValueError: + If *value* or *bits* have invalid values or types. + """ + if not isinstance(value, int): + raise TypeError(f'value is not an int: {value}') + + if value < 0: + raise ValueError(f'value is negative: {value}') + + if bits is not None: + _check_uint(bits) + + if value > maxuint(bits): + raise ValueError(f'value is too large: {value}') + + +def _check_sint(value, bits=None): + """Like _check_uint, but for signed integers.""" + if not isinstance(value, int): + raise TypeError(f'value is not an int: {value}') + + if bits is not None: + _check_uint(bits) + + if value < minsint(bits) or value > maxsint(bits): + raise ValueError(f'value is too large or too small: {value}') + + +def _check_float(value, bits=4*BYTESIZE): + """Like _check_uint, but for floats. + + Raises: + TypeError or ValueError: + If *bits* is not 32 (in addition to the exceptions raised by + _check_uint). + """ + if not isinstance(value, float): + raise TypeError(f'value is not a float: {value}') + + _check_uint(bits) + + if bits != 4*BYTESIZE: + raise ValueError(f'*bits* should be 32, not {bits}') + + try: + struct.pack('>f', value) + except OverflowError: + raise ValueError(f'value is too large or too small: {value}') + + +def _check_bin(value, bits=None): + """Like _check_uint, but for binary strings. + + A binary string is '' or a string of '1's and '0's. + If *bits* is None, it is set to len(value). + + Raises: + ValueError: + If len(value) != bits + (in addition to the exceptions raised by '_check_uint'). + """ + # Print 'repr(value)' instead of 'value', + # since *value* is probably a string. + if not isinstance(value, str): + raise TypeError(f'value is not a str: {repr(value)}') + + if bits is None: + bits = len(value) + + _check_uint(bits) + + if bits != len(value): + raise ValueError( + f'bits != len(value); bits={bits}, value={repr(value)}') + + regex=f'\\A[01]{{{bits}}}\\Z' + if not re.match(regex, value): + raise ValueError( + f'{repr(value)} is not a {bits} bit binary string') + + +def _bin_str(i, bits): + """Return a *bits* bit binary representation of i. + + *i* must be a valid *bits* bit unsigned integer. + + E.g. _bin_str(123, 8) returns '01111011'. + + Raises: + TypeError or ValueError: + If *value* or *bits* have invalid values or types. + """ + _check_uint(i, bits) + + # _bin_str(0, 0) should return '', which is easiest to handle as a special + # case, since the algorithm below cannot produce empty strings. + if (bits == 0): + return '' + + return ( + bin(i) # Returns something like '0b101'. + [2:] # Remove '0b'. + .zfill(bits) # Pad with zeroes to a length of *bits*. + ) diff --git a/dripline/extensions/turbovac/telegram/errors.txt b/dripline/extensions/turbovac/telegram/errors.txt new file mode 100755 index 0000000..715e3f0 --- /dev/null +++ b/dripline/extensions/turbovac/telegram/errors.txt @@ -0,0 +1,66 @@ +# This file contains a database of all the errors raised by the TURBOVAC pump. +# See parameters.txt for a description of the syntax used. + +# Error_code Designation Possible_cause Remedy +1 "Overspeed warning. The actual frequency exceeds the setpoint by over 10 Hz." "Frequency converter defective." "Contact Leybold service." +2 "Pass through time error. The pump has not reached the minimum speed after the maximum run-up time has elapsed." "Forevacuum pressure too high.\n\nGas flow too high.\n\nRotor blocked." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process.\n\nCheck if the rotor turns freely. Contact Leybold service if the rotor is damaged or blocked." +3 "Error threshold pump temperature 3 exceeded. The maximum permissible bearing temperature was exceeded." "Forevacuum pressure too high.\n\nGas flow too high.\n\nFan defective.\n\nWater cooling switched off." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process.\n\nReplace fan.\n\nSwitch on water cooling." +4 "Short circuit error." "" "" +5 "Converter temperature error. Overtemperature at the power output stage or within the frequency converter." "Ambient temperature too high.\n\nPoor cooling." "Ensure max. ambient temperature of 45°C.\n\nImprove cooling." +6 "Run-up time error. The pump has not reached the normal operating frequency after the maximum run-up time." "Forevacuum pressure too high.\n\nGas flow too high." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process." +7 "Motor temperature error. The motor temperature has exceeded the shutdown threshold." "Forevacuum pressure too high.\n\nGas flow too high.\n\nFan defective.\n\nWater cooling switched off." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process.\n\nReplace fan.\n\nSwitch on water cooling." +8 "The pump could not be identified or no pump has been connected." "Pump not correctly connected to the frequency converter.\n\nDefective hardware." "Check the connection between pump and frequency converter.\n\nContact Leybold service." +61 "Low motor temperature warning." "" "" +82 "Fan voltage has failed." "" "" +83 "Motor temperature low warning." "" "" +84 "Motor overtemperature warning." "" "" +85 "Frequency converter collective error." "" "" +86 "Frequency converter collective error." "" "" +87 "Frequency converter collective error." "" "" +88 "Frequency converter collective error." "" "" +89 "Frequency converter collective error." "" "" +90 "Frequency converter collective error." "" "" +91 "Frequency converter collective error." "" "" +92 "Frequency converter collective error." "" "" +93 "Frequency converter collective error." "" "" +94 "Frequency converter collective error." "" "" +95 "Frequency converter collective error." "" "" +96 "Frequency converter collective error." "" "" +97 "Frequency converter internal volume temperature error." "" "" +101 "Overload warning. The pump speed has dropped under the normal operation threshold." "Forevacuum pressure too high.\n\nGas flow too high." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process." +103 "Supply voltage warning. Intermediate circuit voltage too low or maximum time for generator operation was exceeded." "DC supply voltage below 24V.\n\nMains voltage has failed." "Check the voltage at the power supply and if required set up correctly.\n\nRemedy the cause for the mains power failure." +106 "Overload error. The pump speed has dropped under the minimum speed." "Forevacuum pressure too high.\n\nGas flow too high." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process." +111 "The minimum permissible motor temperature is not attained." "Ambient temperature too low.\n\nPump cooling too high." "Ensure min. ambient temperature of 0°C.\n\nReduce water cooling." +116 "The speed of the pump has dropped below the normal operation threshold and has stayed there for a longer period of time." "Forevacuum pressure too high.\n\nGas flow too high." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process." +117 "Motor current error (start-up error). Motor current below nominal current, switchover from open loop controlled to closed loop controlled operation was not successful." "Cable fault.\n\nFaulty connector." "Contact Leybold service." +126 "Defective bearing temperature sensor." "Defective component, short-circuit or broken cable." "Contact Leybold service." +128 "Defective motor temperature sensor." "Defective component, short-circuit or broken cable." "Contact Leybold service." +143 "Overspeed error." "" "" +144 "Bearing break-in function active." "" "Disable bearing break-in function and restart the pump." +225 "Temperature derating active. One of the temperature warning values was exceeded and the maximum permissible motor current was reduced." "" "" +226 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +227 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +228 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +229 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +230 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +231 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +232 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +233 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +234 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +235 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +236 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +237 "Communication error. A communication error on CAN level was determined." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +# The original description was "Communication in error: is initiated when a communication error on CAN level was determined." +238 "Frequency converter collective error." "" "Reset error, try to restart. If this is not possible inform Leybold service or send in the pump." +240 "EEPROM error (CRC). Inconsistent data in the EEPROM." "" "" +252 "Hardware plausibility error. Frequency converter and communication electronics are not from the same pump." "Front end and frequency converter were interchanged." "Establish the correct hardware configuration or run a software update." +600 "Second gauge head stage was not started." "" "Check gauge head and connection. Replace the gauge head if required." +# The remedy in errors 600 - 609 was slightly reworded. +601 "Gauge head lost." "" "Check gauge head and connection. Replace the gauge head if required." +602 "No power supply at the gauge head." "" "Check gauge head and connection. Replace the gauge head if required." +603 "No power from the supply. Return signal from the gauge head output voltage is missing." "" "Check gauge head and connection. Replace the gauge head if required." +608 "Broken filament." "" "Check gauge head and connection. Replace the gauge head if required." +609 "Pirani error." "" "Check gauge head and connection. Replace the gauge head if required." +610 "Inside volume temperature warning." "" "Improve cooling." +611 "Inside volume temperature error." "" "Improve cooling." +612 "Intermediate circuit voltage warning." "" "" \ No newline at end of file diff --git a/dripline/extensions/turbovac/telegram/parameters.txt b/dripline/extensions/turbovac/telegram/parameters.txt new file mode 100755 index 0000000..d9fe968 --- /dev/null +++ b/dripline/extensions/turbovac/telegram/parameters.txt @@ -0,0 +1,128 @@ +# This file contains a database of all the parameters recognized by the TURBOVAC pump. +# All data is copied from the pump manual, with some fixes to spelling, grammar and unclear wording. +# Fields are separated by a single space. +# Fields with internal spaces are enclosed in double quotes (""). +# An empty field is written as "". +# Quotes within a field are written using single quotes ('') and line breaks using \n. +# <-- This is a comment sign. Everything after it on a line is skipped. +# Lines containing only whitespace are skipped. +# The numbers of indexed parameters are written using the syntax "number[first_index:last_index]". +# The degree sign can be copy-pasted from here: °C + +#No. Designation Min. Max. Default Unit r/w Format Description +1 "Device type" 0 65535 180 "" r/w u16 "180 = TURBOVAC 350/450 i\n181 = TURBOVAC 350/450 i with optional interface\n182 = TURBOVAC 350/450 iX\n190 = TURBOVAC 80/200 i\n191 = TURBOVAC 80/200 with optional interface\n192 = TURBOVAC 80/200 iX" +2 "Software version communication electronics x.yy.zz" 0 65535 10000 "" r u16 "x.yy: version, zz: correction index" +3 "Actual frequency" 0 65535 0 Hz r u16 "Actual rotor frequency" +4 "Actual intermediate circuit voltage" 0 1500 30 "0.1 V" r u16 "" +5 "Actual motor current" 0 150 0 "0.1 A" r u16 "" +6 "Actual drive input power" 0 65535 0 "0.1 W" r u16 "" +7 "Actual motor temperature" -10 150 0 °C r s16 "" +8 "Save data command" 0 65535 0 "" r/w u16 "A write command with any value saves temporary data into nonvolatile memory." +# Parameter 8 has the format s16 in the manual, but since the min and max values imply u16, that is used here. +11 "Actual converter temperature" -10 100 0 °C r s16 "" +16 "Motor temperature warning threshold" 0 150 80 °C r/w s16 "Exceeding the motor temperature warning threshold results in a warning." +17 "Nominal motor current" 3 120 50 "0.1 A" r/w u16 "Maximum permissible motor current" +18 "Nominal frequency" 500 2000 1000 Hz r/w u16 "Highest permissible frequency" +19 "Minimum nominal frequency" P20 2000 2000 Hz r/w u16 "Lowest permissible nominal frequency" +20 "Minimum frequency level" 0 2000 2000 Hz r/w u16 "When the pump is accelerating this frequency must be reached within the maximum passing time (P183). At the end of run-up: switch-off threshold at overload." +21 "Motor current threshold" 1 100 100 % r/w u16 "After attaining normal operation and when this threshold is exceeded a 'high load error' will occur after a certain period of time has elapsed." +23 "Pump type/rotor type" -32768 32767 10 0.1 r/w s16 "0 = TURBOVAC i/iX CL (classic)\n1 = TURBOVAC i/iX WR (wide-range)\n2 = TURBOVAC i/iX MI (multi-inlet)" +24 "Setpoint frequency" P19 P18 1000 Hz r/w u16 "Setpoint of the rotor frequency" +25 "Frequency dependent normal operation threshold" 35 99 90 % r/w u16 "Setpoint of the frequency dependent normal operation level" +26[0:2] "Lower temperature switching threshold (for TURBOVAC iX only)" 0 65535 25 °C r/w u16 "Defines the lower temperature switching threshold for the function output.\nX201: index 0 / X202: index 1 / X203: index 2" +27[0:2] "Upper current switching threshold (for TURBOVAC iX only)" 0 65535 40 "0.1 A" r/w u16 "Defines the upper current switching threshold for the function output.\nX201: index 0 / X202: index 1 / X203: index 2" +28[0:2] "Upper frequency switching threshold (for TURBOVAC iX only)" 0 65535 999 Hz r/w u16 "Defines the upper frequency switching threshold for the function output.\nX201: index 0 / X202: index 1 / X203: index 2" +29[0:2] "Relay function selection on X1" 0 8 0 "" r/w u16 "If required, special functions can be assigned to the normal operation and the error relay.\nField 0 specifies the function for normal operation:\n0 = Frequency dependent (=ZSW bit 10)\n1 = Motor current dependent (not applied)\n2 = Fieldbus controlled (=STW bit 12)\n3 = Trigger current bearing temperature (P122)\n4 = Venting function (P247/P248)\n5 = Pump at standstill (=ZSW bit 11)\n6 = Start command is present\n7 = Ready for switch on (=ZSW bit 0)\n8 = Not applied\nField 1 specifies the function for the error relay:\n0 = Energised when an error is present\n1 = Deenergised when an error is present\n2 = Fieldbus controlled\nField 2 specifies the function for the warning relay:\n0 = Energised when a warning is present\n1 = Deenergised when a warning is present\n2 = Fieldbus controlled" +30 "Analog output function" 0 3 0 "" r/w u16 "0 = No function\n1 = Pump temperature P127\n2 = Motor current P5\n3 = Frequency P3\n4 = Input voltage P4\n5 = Measured value of the pressure sensor (available for iX only)" +31[1:2] "Index 1: Upper limit for analog output\nIndex 2: Lower limit for analog output" -32768 32767 [1000,0] 0.1 r/w s16 "" +32 "Max. run-up time" 30 2000 2000 s r/w u16 "Max. permissible time during which the pump must attain the normal operation threshold (P24*P25) with the start signal present." +36 "Start delay time" 0 255 0 "0.1 min" r/w u16 "Delays the start of the pump to allow lead-time for the fore vacuum pump for example. Only active when the pump is under x Hz." +37 "RS485 address" 0 31 0 "" r/w u16 "Parametrizable RS485 address. A change of this parameter setting will only be effective after the power supply has been switched off and on. Bus address does not apply to the USB interface." +38 "Number of start commands" 0 65535 0 "" r/w u16 "Counts pump run-ups." +40 "Error counter total" 0 65535 0 "" r u16 "Counts error messages." +41 "Error counter overload" 0 65535 0 "" r u16 "Counts overload error messages." +43 "Error counter supply" 0 65535 0 "" r u16 "Counts the number of power failures." +119[0:1] "Index 0: Bearing break-in function\nIndex 1: Status bearing break-in function" 0 8 0 "" r/w u16 "Index 0:\n0 = converter starts pump normally\n1 = converter starts with phase 1\n2 = converter starts with phase 2\n4 = converter starts with phase 3\nIndex 1:\n1 = 1st phase active\n2 = 2nd phase active\n4 = 3rd phase active\n8 = 4th phase active" +122[0:2] "Switching threshold for bearing temperature relay output (for TURBOVAC iX only)" 0 65535 40 °C r/w u16 "Temperature at which the relay contact shall be switched on when P29[0]=3. For P125 > P122.\nTURBOVAC i: Unindexed\nTURBOVAC iX: X201: index 0 / X202: index 1 / X203: index 2" +125 "Actual bearing temperature" -10 150 0 °C r s16 "Calculated temperature of the bearing" +126 "Bearing temperature warning threshold" -10 150 60 °C r/w s16 "Exceeding the bearing temperature warning threshold results in a warning." +128 "Motor temperature lower warning threshold" -10 150 5 °C r/w s16 "Falling below the motor temperature lower warning threshold results in a warning." +131 "Motor temperature lower error threshold" -10 150 0 °C r/w s16 "Falling below the motor temperature lower error threshold results in an error message." +132 "Bearing temperature error threshold" -10 150 65 °C r/w s16 "Exceeding the bearing temperature error threshold results in an error message." +133 "Motor temperature error threshold" -10 150 100 °C r/w s16 "Exceeding the motor temperature error threshold results in an error message." +# Uncomment one of the following two lines depending on which model of the pump is in use: +#134 "Function of the accessory connection X201 (for TURBOVAC i only)" 0 65535 7 "" r/w s16 "Selection of the function for the 24V DC output X201.\nThe function of this parameter depends on the pump model (i or iX). Uncomment the line in parameters.txt corresponding to the model in use.\n0 = Always off\n1 = Error\n2 = No error\n3 = Warning\n4 = No warning\n5 = Pump in normal operation\n6 = Pump not in normal operation\n7 = Pump is turning\n8 = Pump at standstill\n18 = Fieldbus controlled (must be enabled to switch via the digital input at X1 the 24V DC output when the control rights have not been assigned to a serial interface)\n19 = Always on\n24 = Trigger current bearing temperature\n25 = Power failure venting\n26 = Pump has start command\n27 = Pump is ready for switching on" +134[0:2] "Function of the accessory connections X201 (index 0) / X202 (index 1) / X203 (index 2) (for TURBOVAC iX only)" 0 65535 [28,34,36] "" r/w u16 "Selection of the function for the 24V DC outputs X201 (index 0) / X202 (index 1) / X203 (index 2).\nThe function of this parameter depends on the pump model (i or iX). Uncomment the line in parameters.txt corresponding to the model in use.\n0 = Always off\n1 = Error\n2 = No error\n3 = Warning\n4 = No warning\n5 = Pump in normal operation\n6 = Pump not in normal operation\n7 = Pump is turning\n8 = Pump at standstill\n18 = Fieldbus controlled (must be enabled to switch via the digital input at X1 the 24V DC output when the control rights have not been assigned to a serial interface)\n19 = Always on\n23 = Motor current dependent\n24 = Trigger current bearing temperature\n25 = Power failure venting\n26 = Pump has start command\n27 = Pump is ready for switching on\n28 = Fan 1 ('pump is turning') (default for X201)\n29 = Fan 2 ('frequency dependent')\n30 = Fan 3 ('bearing temperature dependent')\n31 = Purge gas valve 1 ('normally open')\n32 = Purge gas valve 2 (normally closed)\n33 = Purge gas valve 3 ('start command')\n34 = Relay box for backing pump ('start command') (default for X202)\n35 = Relay box for backing pump 2 ('current dependent')\n36 = Venting valves ('frequency dependent') (default for X203)\n37 = Acceleration of the pump\n38 = Delay of the pump\n39 = Pressure dependent" +# Parameter 134 has the format s16 in the manual, but since the min and max values imply u16, that is used here. +140 "Intermediate circuit current" 0 150 0 "0.1 A" r s16 "Mean value measurement of the intermediate circuit current. Corresponds to the current consumption of the frequency converter." +150 "Standby frequency" 0 1000 800 Hz r/w u16 "Standby operation frequency setpoint" +171[0:253] "Error code memory" 0 65535 0 "" r u16 "Indexed parameter for storing the most recent 254 error codes. The individual error memory entries are accessed via this parameter with an additional index number. The latest error code is accessed with index 0 and the oldest with index 253." +174 "Rotational frequency at the time of an error" 0 65535 0 Hz r u16 "Access analogously as for parameter 171" +176 "Operat. hours count at the time of an error" 0 2147483647 0 "0.01 h" r s32 "Access analogously as for parameter 171" +179 "Response when cancelling the control rights or in the case of a communication interruption of the bus adapter" 0 65535 0 "" r/w u16 "Behaviour in case bit 10 in the control word of the bus adapter is cancelled or when interrupting the communication between converter and bus adapter (see also P182). Here it is assumed that the respective bus adapters perform a cyclic communication on the USS side, so that the respective converter electronics is capable of detecting a communication interruption.\nThe bits in parameter 179 represent an equivalent to the control word in the USS protocol. The actions linked to these bits are run provided bit 10 in the control word (USS protocol for bus adapter) is cancelled or if there are interruptions in the communication between converter and bus adapter.\nHere bit 10 is of special significance:\nBit 10 = 0: The control rights are returned to the next lower priority level. All other bits are not relevant.\nBit 10 = 1: The control rights remain unchanged. The actions linked to the other bits are run." +180 "USS response delay time" 0 20 10 ms r/w u16 "Pause time between received and transmitted telegram (minimum transmit pause). We recommend not to change the default setting (10ms)." +182 "Delay when cancelling the control rights of the bus adapter and timeout in the case of a communication interruption" 0 65535 100 "0.1 s" r/w u16 "Defines the time characteristic when cancelling bit 10 in the control word of the USS protocol or when an interruption in the communication between bus adapter and converter and electronics is detected. Handling when cancelling bit 10 or when there is an interruption on the communication side of the USS bus adapter is the same.\nValue 0.0: Indefinite time delay. In this way a change of the control right is inhibited.\nValues 0.1...6553.5: A change in the control right corresponding to the setting of parameter 179 is only effected after the time span defined through parameter 182 has elapsed." +183 "Max. passing time" 0 1800 500 s r/w u16 "Max. permissible time during which the pump must - with the start signal present - have passed through the critical speed range between 60 Hz and P20." +184 "Converter operating hours" 0 2147483647 0 "0.01 h" r s32 "Counts the operating hours of the converter during active pump operation." +185 "Max. converter DC input current" 0 100 90 "0.1 A" r/w u16 "" #The unit is "0.1" in the manual, but it should probably be "0.1 A" +227 "Active warnings described bit per bit" 0 65535 0 "" r/w u16 "See section 7 of the manual." +247 "Vent on frequency" 0 P18 999 Hz r/w u16 "Frequency at which the venting valve shall be switched on in the event of a mains power failure. Power failure venting can be enabled through P134." +248 "Vent off frequency" 0 P18 5 Hz r/w u16 "Frequency at which the venting valve shall be switched off in the event of a mains power failure. Power failure venting can be enabled through P134." +249 "Generator mode" 0 1 1 "" r/w u16 "P249 = 0: No return feeding in to the DC supply\nP249 = 1: Return feeding in to the DC supply\nNotice: take note of the maximum generator power of 160 W as otherwise the electronics may suffer damage." +312[0:17] "Converter part number (indices 0...17)" 0 127 0 "" r/w u16 "Converter part number. One ASCII character per index." +313[0:17] "Product name (indices 0...17)" 0 127 0 "" r/w u16 "No description in the manual, but probably uses the same format as P312." +314[0:26] "Configuration text (indices 0...26)" 0 127 0 "" r/w u16 "No description in the manual, but probably uses the same format as P312." +315[0:10] "Converter serial number (indices 0...10 usable)" 0 127 0 "" r/w u16 "Converter serial number. One ASCII character per index." +316[0:17] "Converter hardware version (indices 0...17)" 0 127 0 "" r/w u16 "No description in the manual, but probably uses the same format as P312." +349[0:17] "Pump parameter set (indices 0...17)" 0 127 0 "" r/w u16 "Document number of the pump specific parameters set. No format description in the manual, but probably uses the same format as P312." +350[0:17] "Pump part number (indices 0...17)" 0 127 0 "" r/w u16 "No description in the manual, but probably uses the same format as P312." +355[0:17] "Pump serial number (indices 0...17)" 0 127 0 "" r/w u16 "No description in the manual, but probably uses the same format as P312." +394[0:17] "Communication electronics part number (indices 0...17)" 0 127 0 "" r/w u16 "No description in the manual, but probably uses the same format as P312." +395[0:17] "Communication electronics serial number (indices 0...17)" 0 127 0 "" r/w u16 "No description in the manual, but probably uses the same format as P312." +396[0:17] "Communication electronics hardware version (indices 0...17)" 0 127 0 "" r/w u16 "No description in the manual, but probably uses the same format as P312." +601 "Gauge head equipment type (TURBOVAC iX only)" 0 65535 0 "" r u16 "0 = None\n1 = CTR\n2 = TTR 9x\n3 = TTR 100\n4 = PTR 90\n5 = PTR 2xx\n6 = ITR\n7 = DI 200\n8 = DI 2000\n9 = Measuring instrument\n11 = DU 200\n12 = DU 2000" +602 "Gauge head subtype (TURBOVAC iX only)" 0 65535 0 "" r/w u16 "CTR:\n0 = No subtype information\n1 = 0.1 Torr\n2 = 1 Torr\n3 = 10 Torr\n4 = 100 Torr\n5 = 1000 Torr\n6 = 20 Torr" +604 "Gauge head status word (TURBOVAC iX only)" 0 4294967295 0 "" r u32 "Bit 00 = Power supply okay\nBit 01 = Status\nBit 02 = Degassing active\nBit 03 = Error\nBit 04 = Above upper measurement range\nBit 05 = Below lower measurement range\nBit 12 = Maintenance required\nBit 14 = Warning" +606 "Gauge head control word (TURBOVAC iX only)" 0 4294967295 0 "" r/w u32 "Bit 01 = Degassing" +609 "Gas type correction factor available (TURBOVAC iX only)" 0 65535 0 "" r u16 "Bit encoded information which type of gas can be selected\nBit 00 = Air_N2_CO_O2\nBit 01 = CO2\nBit 02 = He\nBit 03 = Ne\nBit 04 = Ar\nBit 05 = Kr\nBit 06 = Xe\nBit 07 = H2\nBit 10 = Customer specific" +# The following real-type parameters have limits of 1.401E-42 and 3.403E+41 in the manual. +# This seems to be an error, since the maximum value for a 32-bit float is 3.402823466E+38, which is smaller than the maximum value given in the manual. +# In addition, many of these parameters have a default value of 0, which is smaller than the minimum value. +# Based on tests wih the pump, it seems that the parameters actually accept the fullrange of floats; +# i.e. the minumum and maximum values are +-3.402823466E+38. +610 "Gas type correction factor (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 "" r real32 "Indicates the currently active gas type correction factor." +611 "Customer specific gas type correction factor (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 1 "" r/w real32 "Value for customer specific gas type correction factor, active at P620=10" +615 "Filtering time (TURBOVAC iX only)" 0 3 3 "" r/w u16 "Size of the ring memory for averaging the pressure value\n0 = 1\n1 = 50\n2 = 100\n3 = 200" +616 "Gauge head pressure value in mbar (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 mbar r real32 "Current pressure value of the gauge head in mbar" +617 "Pressure value of the gauge head in torr (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 Torr r real32 "Current pressure value of the gauge head in torr" +618 "Pressure value of the gauge head in Pa (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 Pa r real32 "Current pressure value of the gauge head in Pa" +619 "Gauge head measurement voltage (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 V r real32 "Current voltage value of the gauge head measurement signal" +620 "Gas type (TURBOVAC iX only)" 0 65535 0 "" r/w u16 "Indicates the setup gas type correction factor\n0 = Air_N2_CO_O2\n1 = CO2\n2 = He\n3 = Ne\n4 = Ar\n5 = Kr\n6 = Xe\n7 = H2\n10 = Customer specific" +623 "System warning bits (TURBOVAC iX only)" 0 65535 0 "" r/w u16 "Bit 00 = Inside volume temperature\nBit 01 = Intermediate circuit voltage not within the nominal range" +624 "Gauge head warning bits (TURBOVAC iX only)" 0 65535 0 "" r/w u16 "Bit 00 = Second stage not started" +625 "Pump start function (TURBOVAC iX only)" 0 65535 0 "" r/w u16 "0 = Pump starts with start signal\n1 = Pump starts pressure dependent" +634[0:2] "Status word accessory output X201 (index 0) / X202 (index 1) / X203 (index 2) (TURBOVAC iX only)" 0 4294967295 0 "" r u32 "Pump:\nBit 03 = Error\nBit 10 = Normal operation := 10\nBit 14 = Warning := 14\nSpecial:\nBit 03 = Error\nBit 10 = Setpoint reached\nBit 14 = Warning\nValve:\nBit 03 = Error\nBit 10 = Valve in position\nBit 14 = Warning" +636[0:2] "Control word accessory output X201 (index 0) / X202 (index 1) / X203 (index 2) (TURBOVAC iX only)" 0 4294967295 0 "" r/w u32 "Pump:\nBit 00 = Start\nBit 07 = Reset\nBit 10 = Control right\nSpecial:\nBit 00 = Operate\nBit 07 = Reset\nBit 10 = Control right\nValve:\nBit 00 = Open\nBit 07 = Reset\nBit 10 = Control right" +643[0:2] "Accessory output switch-on delay (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 s r/w real32 "X201: index 0 / X202: index 1 / X203: index 2" +644[0:2] "Accessory output switch-off delay (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 s r/w real32 "X201: index 0 / X202: index 1 / X203: index 2" +647[0:2] "Lower frequency switching threshold (TURBOVAC iX only)" 0 65535 5 Hz r/w u16 "X201: index 0 / X202: index 1 / X203: index 2" +648[0:2] "Upper pressure switching threshold (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 mbar r/w real32 "X201: index 0 / X202: index 1 / X203: index 2" +649[0:2] "Lower pressure switching threshold (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 mbar r/w real32 "X201: index 0 / X202: index 1 / X203: index 2" +652[0:2] "Lower current switching threshold (TURBOVAC iX only)" 0 65535 15 "0.1 A" r/w u16 "X201: index 0 / X202: index 1 / X203: index 2" +670 "Communication electronics temperature (TURBOVAC iX only)" 0 65535 0 °C r/w u16 "Current temperature of the communication electronics" +671 "Communication electronics temperature warning threshold (TURBOVAC iX only)" 0 65535 75 °C r/w u16 "When the communication electronics warning temperature threshold is exceeded, a warning message is output." +672 "Communication electronics temperature error threshold (TURBOVAC iX only)" 0 65535 80 °C r/w u16 "When the communication electronics temperature shutdown switching threshold is exceeded, an error message is output" +673 "Communication electronics software version (TURBOVAC iX only)" 0 65535 0 "" r u16 "x.yy: version, zz: correction index" +678[0:253] "Equipment error code (TURBOVAC iX only)" 0 65535 0 "" r u16 "Equipment error: number code indicates the error source\n101 = Pump\n201 = Gauge head\n1 = System\n(Index 0 to 253)" +679[0:253] "Electronics operating time upon error occurrence (TURBOVAC iX only)" 0 4294967295 0 0.01h r u32 "(Index 0 to 253)" +682 "Electronics operating hours (TURBOVAC iX only)" 0 4294967295 0 "0.01 h" r/w u32 "" +686 "Pressure switching threshold for the function of pressure dependent starting of the pump (TURBOVAC iX only)" -3.402823466E+38 3.402823466E+38 0 mbar r/w real32 "The manual doesn't tell whether this parameter is writable or not." +690[1:2] "Index 1: Upper limit for analog output\nIndex 2: Lower limit for analog output" -3.402823466E+38 3.402823466E+38 0 "" r/w real32 "The manual doesn't tell whether this parameter is writable or not." +918 "Set bus address parameter" 0 126 126 "" r/w u16 "" +923 "Active bus address" 0 126 126 "" r u16 "" +924 "Type of bus address" 0 1 1 "" r/w u16 "" +1025 "Reset to factory default" 0 65535 0 0 r/w u16 "Initialisation of the parameters to their default values" +1035[0:17] "Pump serial number" 0 127 0 "" r/w u16 "Used for parts identification (index 0 to 17)" +1100 "Drive electronics software version x.yy.zz" 0 65535 10000 "" r u16 "x.yy: version, zz: correction index" +1101 "Frequency converter temperature warning threshold" 0 90 75 °C r/w s16 "Temperature above which an overtemperature warning is output." +1102 "Frequency converter temperature error threshold" 0 90 80 °C r/w s16 "Temperature above which an overtemperature error is output." \ No newline at end of file diff --git a/dripline/extensions/turbovac/telegram/parser.py b/dripline/extensions/turbovac/telegram/parser.py new file mode 100755 index 0000000..cb29f68 --- /dev/null +++ b/dripline/extensions/turbovac/telegram/parser.py @@ -0,0 +1,591 @@ +"""This module contains a parser that reads data about pump parameters, +errors and warnings from text files. + +Attributes: + PARAMETERS: + The pump has multiple parameters which affect its behaviour. + This attribute holds a :class:`dict` of these parameters, represented + as :class:`Parameter` objects, with the numbers of the parameters as + the keys. + + ERRORS: + A :class:`dict` of different error conditions which can affect the + pump, represented as :class:`ErrorOrWarning` objects, + with error numbers as the keys. + + WARNINGS: + Like :attr:`ERRORS`, but for warnings instead of errors. +""" + +import re +import ast +import os.path +from dataclasses import dataclass +from collections import OrderedDict +from typing import Union, List + +from .datatypes import Data, Uint, Sint, Float + + +@dataclass +class Parameter: + """A class for representing pump parameters. + + This is a :obj:`~dataclasses.dataclass`, so the ``__init__``, ``__str__`` + and ``__repr__`` methods are generated automatically. + """ + + number: int + """The number of the parameter.""" + + name: str + """The name of the parameter.""" + + indices: range + """A :class:`range` object describing the indices of the parameter; e.g. + ``range(5)`` would mean the parameter has indices numbered 0 to 4. + This is ``range(0)`` for unindexed parameters. + """ + + min_value: Union[Data, str] + """The minimum value of the parameter. + + The minumum value of some parameters depends on the current value of + another parameter. In that case, :attr:`min_value` is set to + ``'P'``; e.g. 'P18' means the value of parameter 18. + """ + + max_value: Union[Data, str] + """The maximum value of the parameter. + + The maximum value of some parameters depends on the current value of + another parameter. In that case, :attr:`max_value` is set to + ``'P'``; e.g. 'P18' means the value of parameter 18. + """ + + default: Union[Data, List[Data]] + """The default value of the parameter. + + If the parameter is indexed and different indices have different default + values, :attr:`default` will be a :class:`list` of default values + corresponding to the different indices. + """ + + unit: str + """The unit of the parameter; e.g. ``'°C'`` for degrees Celsius.""" + + writable: bool + """Signifies whether the value of the parameter can be changed.""" + + datatype: type + """The type of the value of the parameter; a + :class:`~turboctl.telegram.datatypes.Data` subclass + (but not :class:`~turboctl.telegram.datatypes.Bin`). + """ + + bits: int + """The size of the parameter in bits; ``16`` or ``32``.""" + + description: str + """A string describing the parameter.""" + + @property + def fields(self): + """Return an :class:`~collections.OrderedDict` with the attribute + names of this objects as keys and their values as values. + """ + fieldnames = ['number', 'name', 'indices', 'min_value', 'max_value', + 'default', 'unit', 'writable', 'datatype', 'bits', + 'description'] + return OrderedDict((name, getattr(self, name)) for name in fieldnames) + + +@dataclass +class ErrorOrWarning: + """A class for representing pump errors and warnings.""" + + number: int + """The number of the error or warning.""" + + name: str + """The name of the error or warning.""" + + possible_cause: str + """A string describing the possible cause(s) of the error or warning.""" + + remedy: str + """A string describing possible remedies for the error or warning.""" + + @property + def fields(self): + """Return an :class:`~collections.OrderedDict` with the attribute + names of this object as keys and their values as values. + """ + fieldnames = ['number', 'name', 'possible_cause', 'remedy'] + return OrderedDict((name, getattr(self, name)) for name in fieldnames) + + +# The special character used for comments: +_COMMENT_CHAR = '#' + + +def main(): + """Define :attr:`PARAMETERS`, :attr:`ERRORS` and :attr:`WARNINGS`. + + This function is automatically executed when this module is + imported or run as a script. + """ + global PARAMETERS, ERRORS, WARNINGS + PARAMETERS = load_parameters() + ERRORS = load_errors() + WARNINGS = load_warnings() + +def _fullpath(filename): + """Return of the full path of *filename*. + *filename* should be a file in the same directory as this module. + """ + dirpath = os.path.dirname(__file__) + return os.path.join(dirpath, filename) + + +def load_parameters(path=None): + """Parse the parameter file and return a dictionary of + parameters. + + This function is automatically called by :func:`main`, but it can also be + called separately in order to create another set of parameters for testing + purposes. + + Args: + path: A text file containing parameter/error/warning data. + If *path* isn't specified, the file will be ``'parameters.txt'``, + located in the same directory as this module. + The syntax used to define the parameters is explained in + ``parameters.txt``. + + Returns: + A :class:`dict` with numbers as keys and :class:`Parameter` objects as + values. + + Raises: + FileNotFoundError: If *filename* cannot be found. + RuntimeError: If a line in *filename* cannot be parsed. + """ + default = 'parameters.txt' + filename = path if path else _fullpath(default) + return _load_data(filename, 'parameter') + + +def load_errors(path=None): + """Parse the error file and return a dictionary of + errors. + + This works like :func:`load_parameters`, but the default file is + ``'errors.txt'``, and the returned :class:`dict` contains + :class:`ErrorOrWarning` objects. + """ + default = 'errors.txt' + filename = path if path else _fullpath(default) + return _load_data(filename, 'error') + + +def load_warnings(path=None): + """Parse the warning file and return a dictionary of + warnings. + + This works like :func:`load_parameters`, but the default file is + ``'warnings.txt'``, and the returned :class:`dict` contains + :class:`ErrorOrWarning` objects. + """ + default = 'warnings.txt' + filename = path if path else _fullpath(default) + return _load_data(filename, 'warning') + + +def _load_data(filename, type_): + """Return a dictionary of parameters, errors or warnings. + + Args: + filename: A text file containing parameter/error/warning data. + The syntax used in the text file is explained in + ``parameters.txt``. + type_: ``'parameters'``, ``'errors'`` or ``'warnings'``. + + Returns: + A dictionary with numbers as keys and :class:`Parameter` + or :class:`ErrorOrWarning` objects as values. + + Raises: + FileNotFoundError: If *filename* cannot be found. + RuntimeError: If a line in *filename* cannot be parsed. + """ + + with open(filename, 'r') as file: + contents = file.read() + + lines = contents.split('\n') + + object_list = [] + for i, line in enumerate(lines): + try: + parsed = _parse(line, type_) + except ValueError as e: + raise RuntimeError( + f'Line {i+1} of {filename} could not be parsed: ' + str(e)) + + # *parsed* is None for empty lines; skip those. + if parsed: + object_list.append(parsed) + + return {p.number: p for p in object_list} + + +def _parse(line, type_): + """Parse data from *line* and form a Parameter or ErrorOrWarning object. + + Args: + line: A string with no line breaks. + type_: 'parameter', 'error' or 'warning'. + + Raises: + ValueError: If *line* cannot be parsed. + """ + # This method is private since it's only used internally by this module, + # but it cannot be removed/renamed without changing the tests, since a + # lot of them use this. + + line = _remove_comments(line) + + if _is_empty(line): + return None + + number_of_fields = {'parameter': 9, 'error': 4, 'warning': 4} + fields = _separate_fields(line) + + if len(fields) != number_of_fields[type_]: + raise ValueError( + f'{len(fields)} fields instead of {number_of_fields[type_]}') + + fields = [_add_linebreaks(_remove_quotes(field)) for field in fields] + + try: + return _form_object(fields, type_) + except ValueError as e: + raise ValueError('invalid values: ' + str(e)) from e + + +def _separate_fields(line): + """Separate the data fields contained in *line*. + + Args: + line: A string with no line breaks. + + Returns: + A list containing the data fields. + E.g. _separate_fields('1 2 "3 4"') -> ['1', '2', "3 4"]. + """ + + start = '(?: |^)' + # A regex that begins a data field. + # A space or start of string. + + # ?: makes groups non-capturing. Example: + # re.findall('(?:abc)(?:def)', 'abcdef') -> ['abcdef'], but + # re.findall('(abc)(def)', 'abcdef') -> [('abc', 'def')] + + end = '(?=(?:$| ))' + # A regex that ends a data field. + # A space or end of string + + # ?= makes a regexp non-consuming; i.e. a space can here match both + # the end of one word and the start of another, since the end match + # doesn't consume it. + + word = '(?:[^ "]+)' + # A regex representing a string without any spaces. + # Any character except space or quote, 1 or more times. + + many_words = '(?:"[^"]*")' + # A regex representing a string consisting of multiple words + # separated by spaces; all enclosed in double quotes. + # Any character except quote, 1 or more times, between quotes. + + one_or_many_words = f'({word}|{many_words})' + field = start + one_or_many_words + end + return re.findall(field, line) + + +def _form_object(fields, type_): + """Return a Parameter or ErrorOrWarning object. + + Args: + fields: A list containing the attribute values of the + object as strings. + type_: 'parameter', 'error' or 'warning'. + + Raises: + ValueError: If type_ is not any of the above. + """ + + if type_ == 'parameter': + return _form_parameter(fields) + if type_ in ('error', 'warning'): + return _form_error_or_warning(fields) + + raise ValueError( + f"*type_* should be 'parameter', 'error' or 'warning', not {type_}") + + +def _form_parameter(fields): + """Construct a Parameter object from given data fields. + + Raises: + ValueError: If any of the data fields cannot be parsed into + values of the correct type. + """ + + number, indices = _parse_number( fields[0]) + name = fields[1] + unit = fields[5] + writable = _parse_writable(fields[6]) + datatype, bits = _parse_format( fields[7]) + description = fields[8] + + min_value = _parse_minmax( fields[2], datatype, bits) + max_value = _parse_minmax( fields[3], datatype, bits) + default = _parse_default( fields[4], datatype, bits) + + return Parameter(number, name, indices, min_value, max_value, default, + unit, writable, datatype, bits, description) + + +def _form_error_or_warning(fields): + """Construct an ErrorOrWarning object from given data fields. + + Raises: + ValueError: If any of the data fields cannot be parsed into + values of the correct type. + """ + number, _ = _parse_number(fields[0]) + name = fields[1] + possible_cause = fields[2] + remedy = fields[3] + + return ErrorOrWarning(number, name, possible_cause, remedy) + + +def _parse_number(string): + """Parse the data field containg the parameter/error/warning + number and possible indices. + + Returns: + A tuple of the number (an int) and the indices (a range object; + range(0) for unindexed parameters/errors/warnings). + """ + any_number = '([0-9]+)' + no_capture = '?:' + one_or_zero_times = '?' + + index_numbers = f'\\[{any_number}:{any_number}\\]' + # [:] + maybe_index_numbers = f'({no_capture}{index_numbers}){one_or_zero_times}' + # [:] or nothing + + numbers = f'^{any_number}{maybe_index_numbers}$' + # [:] or + + try: + parts = re.findall(numbers, string)[0] + # If re.findall is successful, it will return + # [('', '', '')] or [('', '', '')]. + # If it's unsuccessful, it will return []. + + except IndexError: + raise ValueError(f'invalid number or indices: {string}') + + number = int(parts[0]) + + if parts[1] == parts[2] == '': + indices = range(0) + else: + try: + indices = range(int(parts[1]), int(parts[2]) + 1) + # parts[2] is the last index, so 1 has to be added to it + # for it to be included in the range. + except ValueError: + raise ValueError(f'invalid indices: {string}') + + return number, indices + + +def _parse_minmax(string, datatype, bits): + """Parse a data field containg the minimum or maximum parameter + value. + + Args: + string: The field to be parsed. + datatype: The datatype (a Data subclass) of the parameter. + bits: The size of the parameter in bits. + + Returns: + -An object of type *datatype*, if the value is numeric. + -A string in the format P, if *string* matches that + format. + """ + # Try to parse the string as an int or a float first. + try: + return _parse_value(string, datatype, bits) + except ValueError: + pass + + # If that doesn't work, try to interpret the string as a reference. + any_number = '([0-9]+)' + P_number = f'^P{any_number}$' + + if re.match(P_number, string): + return string + else: + raise ValueError(f'invalid min/max value: {string}') + + +def _parse_default(string, datatype, bits): + """Parse the data field containg the default parameter value. + + Args: + string: The field to be parsed. + datatype: The datatype (a Data subclass) of the parameter. + bits: The size of the parameter in bits. + + Returns: + An object of type *datatype*. If different parameter indices have + different default values, a list of such objects is returned instead. + """ + # Try to parse the string as an int or a float first. + try: + return _parse_value(string, datatype, bits) + except ValueError: + pass + + # If that doesn't work, try to interpret the string as a list of + # ints/floats. + try: + value_list = ast.literal_eval(string) # Unlike eval(), this is safe. + # By converting i to a string first we can use _parse_value to + # e.g. parse '0' into a Float, even though Float(0) raises an error. + return [_parse_value(str(i), datatype, bits) for i in value_list] + except (SyntaxError, ValueError): + raise ValueError(f'invalid default value: {string}') + + +def _parse_value(string, datatype, bits): + """Parse *string* to an int or a float and the convert that into a *bits* + bit instance of *datatype*. + + Raises: + ValueError: If *string* isn't a valid int or a float.' + """ + # This is needed, because Uint, Sint, and Float don't accept str arguments. + builtin_type = float if datatype == Float else int + value = builtin_type(string) + return datatype(value, bits) + + +def _parse_format(string): + """Parse the data field containg the parameter number format. + + Returns: A tuple containing the type (a subclass of Data) + and bits (16 or 32) of the parameter. + """ + numbers = '([0-9]+)' + letters = '([a-z]+)' + letters_and_numbers = f'^{letters}{numbers}$' + + try: + parts = re.findall(letters_and_numbers, string)[0] + # If re.findall is successful, it will return + # [('', '')]. + # If it's unsuccessful, it will return []. + + except IndexError: + raise ValueError(f'invalid format: {string}') + + type_part = parts[0] + bits_part = parts[1] + + if type_part == 'u': + type_ = Uint + elif type_part == 's': + type_ = Sint + elif type_part == 'real': + type_ = Float + else: + raise ValueError(f'invalid type: {string}') + + try: + bits = int(bits_part) + + except ValueError: + raise ValueError(f'invalid bits: {string}') + + return type_, bits + + +def _parse_writable(string): + """Parse the data field indicating whether the parameter can be + written to an return a boolean (True for writable, False otherwise). + """ + if string == 'r/w': + return True + elif string in ('r', ''): + return False + else: + raise ValueError(f'invalid r/w string: {string}') + + +def _remove_comments(line): + """Removes everything after a comment symbol from *line*.""" + return line.split(_COMMENT_CHAR)[0] + + +def _is_empty(line): + """Returns True if *line* consists only of whitespace.""" + empty_regex = '^\\s*$' # \s = whitespace character + return bool(re.match(empty_regex, line)) + + +def _remove_quotes(string): + """If *string* is enclosed in double quotes, this function removes + them. + + E.g. both 'Lorem ipsum' and '"Lorem ipsum"' return 'Lorem ipsum'. + """ + + if string[0] == string[-1] == '"': + return string[1:-1] + else: + return string + + +def _add_linebreaks(string): + """Replaces occurrences of r'\n' in *string* with '\n'. + + Line breaks can be manually inserted inside data fields in a + text file by typing a backslash and a 'n'. These are two + separate characters, and need to be replaced by a single + line break character '\n'. + """ + + # The Python interpreter and the re module seem to apply special + # characters separately, so we need to escape the backslash + # character twice. + # r'\\' equals two backslashes (r denotes a raw string, where no + # special characters are applied) and the re module interprets + # this as a single backslash. + # '\\\\' also works, since it is equal to r'\\'. + + # Here r'\\n' is interpreted by the re module as a single + # backslash and the letter n. + return re.sub(r'\\n', '\n', string) + + +main() diff --git a/dripline/extensions/turbovac/telegram/telegram.py b/dripline/extensions/turbovac/telegram/telegram.py new file mode 100755 index 0000000..78ee8c2 --- /dev/null +++ b/dripline/extensions/turbovac/telegram/telegram.py @@ -0,0 +1,693 @@ +"""This module defines classes for creating, representing and reading +telegrams which are used to communicate with the pump. + +.. + Aliases for Sphinx. + +.. |Uint| replace:: :class:`~turboctl.telegram.datatypes.Uint` +.. |Sint| replace:: :class:`~turboctl.telegram.datatypes.Sint` +.. |Float| replace:: :class:`~turboctl.telegram.datatypes.Float` +.. |Bin| replace:: :class:`~turboctl.telegram.datatypes.Bin` +""" + +from dataclasses import dataclass + +from .codes import ( + ControlBits, StatusBits, get_parameter_code, get_parameter_mode, + ParameterResponse, ParameterError +) +from .datatypes import (Data, Uint, Sint, Bin) +from .parser import PARAMETERS + + +@dataclass +class Telegram: + """A simple dataclass that represents a telegram sent to or from the pump. + + This class is cumbersome to initialize directly, since the values of all + attributes must be given as arguments. Instances should instead be + created with the :class:`TelegramBuilder` class. + + The Leybold TURBOVAC i/iX vacuum pump communicates with a computer + via its RS 232 or RS 485 serial port or a USB port using telegrams + of 24 bytes. The general structure of the telegrams follows the + USS protocol. + + Each byte consists of a start bit (0), 8 data bits, an even + parity bit (1 if there are an even number of 1s in the data + bits, 0 otherwise) and an ending bit (1). However, only the data + bits are included in the bytes objects that represent telegrams; + the `serial `_ + module automatically adds the other bits. + + In the TURBOVAC manual, the data bits in a byte are indexed as + [7,6,5,4,3,2,1,0] (i.e. according to the power of 2 they + represent), but this class uses the convention [0,1,2,3,4,5,6,7], + because it corresponds to the indices of a Python list. + + The functions and values of the different bytes in a telegram + are detailed below. Each list entry contains the name of the + :class:`Telegram` object attribute (if any) that represents + the value associated with the entry. + + Unless otherwise noted, all bytes have a default value of 0. + + **Byte 0:** STX (start of text). Always 2. + + **Byte 1:** LGE (telegram length). + Excludes bytes 0 and 1, and thus always has a value of 22. + + **Byte 2:** ADR (address). + With a RS485 port this denotes the slave node number (0-31). + Reading or changing this byte is not currently supported by this + class. + + **Bytes 3-4:** PKE (parameter number and type of access). + A 16 bit block: + + - Bits 0-3: + Type of parameter access or response. + This is a 4 bit code indicating e.g. whether a parameter + should be read from or written to. + Valid codes are detailed in the :mod:`~turboctl.telegram.codes` + module. + + Attribute: :attr:`parameter_code`. + + - Bit 4: + Always 0. + + - Bits 5-15: + The number of the parameter to be accessed. + + Attribute: :attr:`parameter_number`. + + **Byte 5:** - (reserved). Always 0. + + **Byte 6:** IND (parameter index). + If the requested parameter is indexed, this specifies the number + of the requested index. + + Attribute: :attr:`parameter_index`. + + **Bytes 7-10:** PWE (parameter value). + This block contains a parameter value that is written to or read + from the pump. If the pump tries to access a parameter but fails, the + reply will contain an error code in this block. + + Attribute: :attr:`parameter_value`. + + **Bytes 11-12:** PZD1 (status and control bits). + 16 bits each corresponding to a single setting or command which + can be turned on by setting the bit to 1. + In a reply from the pump these correspond to status + conditions affecting the pump instead of commands. + + Attribute: :attr:`flag_bits`. + + **Bytes 13-14:** PZD2 (current rotor frequency). + Rotor frequency in Hz; the same as parameter 3. + Included in all replies, and can be included in queries to define + a setpoint for the frequency. (This only works if the setpoint is + enabled through the control bits, and overrides the setpoint + defined in parameter 24). + + Attribute: :attr:`frequency`. + + **Bytes 15-16:** PZD3 (current frequency converter temperature). + Frequency converter temperature in °C, included in all replies. + Same as parameter 11. + + Attribute: :attr:`temperature`. + + **Bytes 17-18**: PZD4 (current motor current). + Motor current in 0.1 A, included in all replies. + Same as parameter 5. + + Attribute: :attr:`current`. + + **Bytes 19-20:** - (reserved). Always 0. + + **Bytes 21-22:** PZD6 (current intermediate circuit voltage). + Intermediate circuit voltage in V, included in all replies. + Same as parameter 4. + + Attribute: :attr:`voltage` + + **Byte 23:** BCC (byte block check): + A checksum computed using the following algorithm: + :: + + checksum = bytes_[0] + for byte in bytes_[1:23]: + checksum = checksum ^ byte_ + + where ``^`` is the exclusive or (XOR) operator. + """ + # The manual uses "stator frequency (=P3)" instead of "rotor frequency", + # but the entry for parameter 3 uses the word "rotor", which seems to be + # the correct version since the rotor is the moving part. + # The manual also lists the unit of voltage as 0.1 V, but the correct unit + # seems to be V. + + # :mod:`serial` links to a weird place in pySerial's documentation, so a + # manual hyperlonk to the front page was used instead. + + parameter_code: Bin + """The parameter access or response code as a 4-bit |Bin|.""" + + parameter_number: Uint + """The parameter number as an 11-bit |Uint|.""" + + parameter_index: Uint + """The parameter index as a 8-bit |Uint|.""" + + parameter_value: Data + """The parameter value. This attribute is always a 32-bit instance of a + subclass of :class:`~turboctl.telegram.datatypes.Data`, + but the exact type depends on the parameter. + """ + + flag_bits: Bin + """The control or status bits as a 32-bit |Bin|.""" + + frequency: Uint + """The frequency as a 32-bit |Uint|.""" + + temperature: Sint + """The temperature as a 32-bit |Sint|.""" + + current: Uint + """The current as a 32-bit |Uint|.""" + + voltage: Uint + """The voltage as a 32-bit |Uint|.""" + + LENGTH = 24 + """The length of a telegram in bytes.""" + + def __bytes__(self): + """Return the telegram as a :class:`bytes` object. + + The checksum is computed automatically and added to the end. + """ + bytes_ = bytes( + Uint(2, 8) + + Uint(22, 8) + + Uint(0, 8) + + self.parameter_code + + Bin('0') + + self.parameter_number + + Uint(0, 8) + + self.parameter_index + + self.parameter_value + + self.flag_bits[::-1] + + self.frequency + + self.temperature + + self.current + + Uint(0, 16) + + self.voltage + ) + return bytes_ + bytes([checksum(bytes_)]) + + +class TelegramBuilder: + """ + TelegramBuilder(parameters=PARAMETERS) + + This class can be used to easily construct instances of the + :class:`Telegram` class. + + Here is an example of how to use this class: + :: + + telegram = (TelegramBuilder().set_parameter_mode('write') + .set_paramerer_number(1) + .set_parameter_index(2) + .set_parameter_value(3) + .build()) + + The above creates a telegram which writes the value 3 to parameter 1, + index 2. Note that this is just an example of the syntax; parameter 1 + isn't actually indexed. + + Attributes which aren't explicitly set to a value with a setter method are + set to zero when the telegram is created. + Trying to set an attribute to an invalid value results in a + :class:`ValueError` or a :class:`TypeError`. + + A telegram can also be created from a :class:`bytes` object: + :: + + telegram = TelegramBuilder().from_bytes(bytes_).build() + + Attributes: + parameters: A :class:`dict` of + :class:`~turboctl.telegram.parser.Parameter` objects, with + parameter numbers as keys. + The default value is :const:`~turboctl.telegram.parser.PARAMETERS`, + but non-default parameter sets can be used for testing purposes. + """ + # The first line of the docstring overrides the default signature generated + # by Sphinx, and thus prevents PARAMETERS from being expanded. + + def __init__(self, parameters=PARAMETERS): + """ + __init__(parameters=PARAMETERS) + + Initialize a new :class:`TelegramBuilder`. + + Args: + parameters: The object to be assigned to :attr:`parameters`. + """ + self.parameters = parameters + + # Keyword arguments used to create a telegram. + self._kwargs = { + 'parameter_code': Bin(4 * '0', bits=4), + 'parameter_number': Uint(0, bits=11), + 'parameter_index': Uint(0, bits=8), + 'parameter_value': Uint(0, bits=32), + 'flag_bits': Bin(16 * '0', bits=16), + 'frequency': Uint(0, bits=16), + 'temperature': Sint(0, bits=16), + 'current': Uint(0, bits=16,), + 'voltage': Uint(0, bits=16), + } + # parameter_value and parameter_mode are special cases. + # These variables store the values given by the user, and the final + # values used as arguments are only determined when the telegram is + # created. + + # __init__ and set_parameter_value set this to a int or a float, + # and from_bytes to a bytes object. + self._parameter_value = 0 + + # __init__ and set_parameter_mode set this to a string, and from_bytes + # to a Bin object. + self._parameter_mode = 'none' + + def from_bytes(self, bytes_): + """Read the contents of the telegram from a :class:`bytes` object. + + The type of :attr:`parameter_value ` + depends on :attr:`parameter_number `, + and is assigned automatically. If *bytes_* contains a parameter + number that doesn't exist, a :class:`ValueError` is raised. + + If the parameter isn't accessed (i.e. the parameter mode is set to + ``'none'`` or code to ``'0000'``), invalid parameter numbers, such as + the default value of 0, are permitted. + In that case, the parameter type is set to + :class:`~turboctl.telegram.datatypes.Uint`. + + Note that this isn't a class method; a :class:`TelegramBuilder` must + first be initialized normally with :meth:`__init__`, after which + this method may be called. + + Raises: + ValueError: If *bytes_* doesn't represent a valid telegram. + """ + self._check_valid_telegram(bytes_) + + code_and_number_bits = Bin(bytes_[3:5]) + self._kwargs = { + 'parameter_number': Uint(code_and_number_bits[5:16]), + 'parameter_index': Uint(bytes_[6]), + 'flag_bits': Bin(bytes_[11:13])[::-1], + 'frequency': Uint(bytes_[13:15]), + 'temperature': Sint(bytes_[15:17]), + 'current': Uint(bytes_[17:19]), + 'voltage': Uint(bytes_[21:23]) + } + + self._parameter_value = bytes_[7:11] + self._parameter_mode = Bin(code_and_number_bits[0:4]) + + return self + + @staticmethod + def _check_valid_telegram(bytes_): + """Raise a ValueError if bytes_ doesn't represent a valid Telegram. + """ + + if len(bytes_) != 24: + raise ValueError(f'len(bytes_) should be 24, not {len(bytes_)}') + + if bytes_[0] != 2: + raise ValueError(f'bytes_[0] should be 2, not {bytes_[0]}') + + if bytes_[1] != 22: + raise ValueError(f'bytes_[1] should be 22, not {bytes_[1]}') + + cs = checksum(bytes_[0:23]) + if cs != bytes_[23]: + raise ValueError(f'bytes_[23] (the checksum) should be ' + f'{cs}, not {bytes_[23]}') + + def set_parameter_mode(self, value: str): + """Set the parameter access or response mode to one of the following: + + Access modes: + - ``'none'`` + - ``'read'`` + - ``'write'`` + + Response modes: + - ``'none'`` + - ``'response'`` + - ``'error'`` + - ``'no write'`` + + The parameter access or response code is determined automatically + based on the parameter mode and the parameter number. + """ + self._parameter_mode = value + return self + + def set_parameter_number(self, value: int): + """Set the parameter number. + + Raises: + ValueError: If there isn't a parameter with the specified number. + """ + self._kwargs['parameter_number'] = Uint(value, 11) + return self + + def set_parameter_index(self, value: int): + """Set the parameter index.""" + self._kwargs['parameter_index'] = Uint(value, bits=8) + return self + + def set_parameter_value(self, value): + """Set the parameter value. + + The type of *value* depends on the type of the parameter. + This method can also be used to set the error code; if + :meth:`set_parameter_mode` is called to set the parameter mode to + ``'error'``, the parameter value is always interpreted as an |Uint| + error code regardless of parameter number or type. + """ + self._parameter_value = value + return self + + def set_flag_bits(self, bits): + """Set the control or status bits. + + *bits* should be an iterable of those + :class:`~turboctl.telegram.codes.ControlBits` or + :class:`~turboctl.telegram.codes.StatusBits` members that should be + included in the telegram. + """ + bitlist = 16 * ['0'] + for bit in bits: + bitlist[bit.value] = '1' + string = ''.join(bitlist) + self._kwargs['flag_bits'] = Bin(string, bits=16) + return self + + def set_frequency(self, value: int): + """Set the frequency.""" + self._kwargs['frequency'] = Uint(value, bits=16) + return self + + def set_temperature(self, value: int): + """Set the temperature. + + Note that *value* can also be negative. + """ + self._kwargs['temperature'] = Sint(value, bits=16) + return self + + def set_current(self, value): + """Set the current.""" + self._kwargs['current'] = Uint(value, bits=16) + return self + + def set_voltage(self, value): + """Set the voltage.""" + self._kwargs['voltage'] = Uint(value, bits=16) + return self + + def build(self, type_='query'): + """Build and return a :class:`Telegram` object. + + Args: + type_: ``query`` if the telegram represents a message to the pump, + ``reply`` if it represents a message from the pump. + + Raises: + :class:`ValueError` or :class:`TypeError`: If a telegram cannot + be created with the specified attributes. + """ + + # Make sure type_ is valid to avoid bugs caused by a misspelled + # argument. + if type_ not in ['query', 'reply']: + raise ValueError(f'invalid type_: {type_}') + + # Determine parameter access code. + + none_code = '0000' + error_code = ParameterResponse.ERROR.value + + # __init__() and set_parameter_mode set self._parameter_mode to a + # string, while from_bytes() sets it to a Bin object. + mode_is_none = self._parameter_mode in ['none', Bin(none_code)] + mode_is_error = (type_ == 'reply' and + self._parameter_mode in ['error', Bin(error_code)]) + + if mode_is_none: + self._kwargs['parameter_code'] = Bin(none_code) + elif mode_is_error: + self._kwargs['parameter_code'] = Bin(error_code) + else: + # parameter_number must be valid if the access mode isn't 'none' + # or 'error'. + number = self._kwargs['parameter_number'].value + try: + parameter = self.parameters[number] + except KeyError: + raise ValueError(f'invalid parameter number: {number}') + + if isinstance(self._parameter_mode, Bin): + # If this object was created from a Bin object, + # self._parameter_mode will be a bytes object. + self._kwargs['parameter_code'] = self._parameter_mode + else: + code = get_parameter_code( + type_, self._parameter_mode, + bool(parameter.indices), parameter.bits + ).value + self._kwargs['parameter_code'] = Bin(code) + + # Determine parameter value. + + if mode_is_none or mode_is_error: + # If the mode is 'none', there is no parameter access and the + # datatype doesn't matter. + # If the mode is 'error', the parameter value is replaced by an + # Uint error code. + datatype = Uint + else: + datatype = parameter.datatype + + if isinstance(self._parameter_value, bytes): + # If self._parameter_value was set by from_bytes(), + # self._parameter_value will be a bytes object and bits cannot be + # specified. + self._kwargs['parameter_value'] = datatype(self._parameter_value) + else: + # If _parameter_value was specified by the user, its type should + # match the type of the parameter. + # The default value of 0 is accepted by all parameter types. + self._kwargs['parameter_value'] = datatype(self._parameter_value, + bits=32) + + return Telegram(**self._kwargs) + + +class TelegramReader: + """This class can be used to read the data of a telegram in a more + user-friendly way. This means the returned values are Python built-ins + instead of the custom datatypes used by the :class:`Telegram` class, and + *parameter_code* is automatically converted to a human-readable value. + + Attributes: + type: + ``'query'`` for telegrams to the pump, ``'reply'`` for telegrams + from the pump. + + telegram: + The :class:`Telegram` object that is read. + """ + + def __init__(self, telegram, type_='reply'): + """Initialize a new :class:`TelegramReader`. + + Args: + telegram: The :class:`Telegram` to be read. + type_: ``query`` if the telegram represents a message to the pump, + ``reply`` if it represents a message from the pump. + + Raises: + :class:`ValueError`: If *type_* has an invalid value. + """ + + # Make sure type_ is valid to avoid bugs caused by a misspelled + # argument. + if type_ not in ['query', 'reply']: + raise ValueError(f'invalid type_: {type_}') + + self.type = type_ + self.telegram = telegram + + def __repr__(self): + """Return an exact string respresentation of this object. + + The returned string can be evaluated to create a copy of this object. + The format is ``ClassName(telegram=, type=)``. + """ + return type(self).__name__ + ( + f'(telegram={self.telegram}, type={repr(self.type)})') + + + def __str__(self): + """Return an easily readable string representation of this object. + + The returned string shows the values of both the attributes and the + read-only properties of this object, and cannot thus be passed to + :func:`eval` without an raising error. + + The format is + + .. highlight:: none + + :: + + ClassName( + telegram=, + type=, + parameter_mode=, + ... + ) + + .. highlight:: default + """ + # str(self.parameter_error) must be used instead of + # self.parameter_error, because the latter is displayed as just a + # number instead of its __repr__ or __str__. This is probably caused by + # the fact that that ParameterError inherits int. + return type(self).__name__ + ('(\n' + f' telegram={self.telegram},\n' + f' type={repr(self.type)},\n' + f' parameter_mode={repr(self.parameter_mode)},\n' + f' parameter_number={self.parameter_number},\n' + f' parameter_index={self.parameter_index},\n' + f' parameter_value={self.parameter_value},\n' + f' parameter_error={str(self.parameter_error)},\n' + f' flag_bits={self.flag_bits},\n' + f' frequency={self.frequency},\n' + f' temperature={self.temperature},\n' + f' current={self.current},\n' + f' voltage={self.voltage}\n' + ')' + ) + + @property + def parameter_mode(self): + """Return the parameter mode. + + This method is the reverse of + :meth:`TelegramBuilder.set_parameter_mode`: it automatically converts + a parameter access or response code to a human-readable string. + + Raises: + ValueError: If the parameter code of the telegram is invalid. + """ + code = self.telegram.parameter_code.value + return get_parameter_mode(self.type, code) + + @property + def parameter_number(self): + """Return the parameter number.""" + return self.telegram.parameter_number.value + + @property + def parameter_index(self): + """Return the parameter index.""" + return self.telegram.parameter_index.value + + @property + def parameter_value(self): + """Return the parameter value.""" + return self.telegram.parameter_value.value + + @property + def parameter_error(self): + """Return the parameter error. + + Returns: + A member of the :class:`~turboctl.telegram.codes.ParameterError` + enum, or ``None``, if :attr:`parameter_mode` isn't ``'error'``. + + Raises: + ValueError: If the error number isn't valid. + """ + # The error code could be easily read from parameter_value + # (which could be changed to always give the value as an Uint if + # parameter_mode is 'error'). This property mostly exists to aid in + # debugging; __str__ displays its value, which makes it easy to see + # which error has occurred without looking up the meaning of the error + # code. + # There's no equivalent TelegramBuilder.set_error_code method, because + # it isn't really needed, and adding that bit of symmetry wouldn't be + # worth the increased complexity. + + if self.parameter_mode != 'error': + return None + + number = Uint(self.telegram.parameter_value).value + try: + return ParameterError(number) + except KeyError: + raise ValueError(f'invalid parameter error number: {number}') + + @property + def flag_bits(self): + """Return the control or status bits as a list of those + :class:`~turboctl.telegram.codes.StatusBits` or + :class:`~turboctl.telegram.codes.ControlBits` members that are set to + 1 in the telegram. + """ + bits = self.telegram.flag_bits.value + enum = ControlBits if self.type == 'query' else StatusBits + return [enum(i) for i, char in enumerate(bits) if char == '1'] + + @property + def frequency(self): + """Return the frequency.""" + return self.telegram.frequency.value + + @property + def temperature(self): + """Return the temperature.""" + return self.telegram.temperature.value + + @property + def current(self): + """Return the current.""" + return self.telegram.current.value + + @property + def voltage(self): + """Return the voltage.""" + return self.telegram.voltage.value + + +def checksum(bytes_: bytes) -> int: + """Compute a checksum for a telegram.""" + checksum = 0 + for i in bytes_: + checksum = checksum ^ i + return checksum diff --git a/dripline/extensions/turbovac/telegram/warnings.txt b/dripline/extensions/turbovac/telegram/warnings.txt new file mode 100755 index 0000000..1793625 --- /dev/null +++ b/dripline/extensions/turbovac/telegram/warnings.txt @@ -0,0 +1,14 @@ +# This file contains a database of all the warnings raised by the TURBOVAC pump. +# See parameters.txt for a description of the syntax used. + +# P227_bit Designation Possible_cause Remedy +0 "Pump temperature 1 has passed the warning threshold." "Forevacuum pressure too high.\n\nGas flow too high.\n\nFan defective.\n\nWater cooling switched off." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process.\n\nReplace fan.\n\nSwitch on water cooling." +1 "Pump temperature 2 has passed the warning threshold." "Forevacuum pressure too high.\n\nGas flow too high.\n\nFan defective.\n\nWater cooling switched off." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process.\n\nReplace fan.\n\nSwitch on water cooling." +2 "Pump temperature 3 has passed the warning threshold." "Forevacuum pressure too high.\n\nGas flow too high.\n\nFan defective.\n\nWater cooling switched off." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process.\n\nReplace fan.\n\nSwitch on water cooling." +3 "The minimum permissible ambient temperature is not reached." "Ambient temperature too low.\n\nPump cooling too high." "Ensure min. ambient temperature of 5 °C.\n\nReduce water cooling." +6 "Overspeed warning. The actual value exceeds the setpoint by more than 10 Hz." "" "Consult Leybold service." +7 "Pump temperature 4 has passed the warning threshold." "Forevacuum pressure too high.\n\nGas flow too high.\n\nFan defective.\n\nWater cooling switched off." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process.\n\nReplace fan.\n\nSwitch on water cooling." +11 "Overload warning. The pump speed has dropped under the normal operation threshold." "Forevacuum pressure too high.\n\nGas flow too high." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process." +12 "Pump temperature 5 has passed the warning threshold." "Forevacuum pressure too high.\n\nGas flow too high.\n\nFan defective.\n\nWater cooling switched off." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.\n\nSeal leak, check process.\n\nReplace fan.\n\nSwitch on water cooling." +13 "Pump temperature 6 has passed the warning threshold." "Forevacuum pressure too high.\n\nGas flow too high.\n\nFan defective.\n\nWater cooling switched off." "Check the ultimate pressure of the backing pump and install a bigger backing pump if required.q\n\nSeal leak, check process.\n\nReplace fan.\n\nSwitch on water cooling." +14 "Power supply voltage warning. Supply voltage failure during active operation of the pump. P4 > Umax or P4 < Umin." "Intermediate circuit voltage too low.\n\nDC power supply voltage below 24 V or 48 V.\n\nMains voltage failure." "" \ No newline at end of file diff --git a/dripline/extensions/turbovac/turbovac_endpoint.py b/dripline/extensions/turbovac/turbovac_endpoint.py new file mode 100644 index 0000000..f049b52 --- /dev/null +++ b/dripline/extensions/turbovac/turbovac_endpoint.py @@ -0,0 +1,88 @@ +''' +A Entity is an enhanced implementation of a Dripline Endpoint with simple logging capabilities. +The Entitys defined here are more broad-ranging than a single service, obviating the need to define new Entitys for each new service or provider. + +When implementing a Entity, please remember: +- All communication must be configured to return a response. If no useful get is possible, consider a \*OPC? +- set_and_check is a generally desirable functionality +''' + +from dripline.core import Entity, calibrate, ThrowReply +from .telegram.datatypes import (Data, Uint, Sint, Bin) +from .telegram.codes import (ControlBits, StatusBits, get_parameter_code, get_parameter_mode, ParameterResponse, ParameterError) +from .telegram.telegram import (Telegram, TelegramBuilder, TelegramReader) + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + +__all__.append('TurboVACTelegramEntity') +class TurboVACTelegramEntity(Entity): + ''' + Entity for turboVAC stuff + ''' + + def __init__(self, + number=None, + index=None, + **kwargs): + ''' + Args: + number (int): sent verbatim in the event of on_get; if None, getting of endpoint is disabled + index (int): sent as set_str.format(value) in the event of on_set; if None, setting of endpoint is disabled + ''' + Entity.__init__(self, **kwargs) + self._number = number + self._index = index + + @calibrate() + def on_get(self): + request_telegram = (TelegramBuilder() + .set_parameter_mode("read") + .set_parameter_number(self._number) + .set_parameter_index(self._index) + .set_parameter_value(0) + .set_flag_bits([ControlBits.COMMAND]) + .build() + ) + + reply_bytes = self.service.send_to_device([bytes(request_telegram)]) + logger.debug(f'raw result is: {reply_bytes}') + reply = TelegramBuilder().from_bytes(reply_bytes).build() + response = TelegramReader(reply, 'reply') + + return response.parameter_value + + def on_set(self, value): + request_telegram = (TelegramBuilder() + .set_parameter_mode("write") + .set_parameter_number(self._number) + .set_parameter_index(self._index) + .set_parameter_value(value) + .set_flag_bits([ControlBits.COMMAND]) + .build() + ) + + reply_bytes = self.service.send_to_device([bytes(request_telegram)]) + logger.debug(f'raw result is: {reply_bytes}') + reply = TelegramBuilder().from_bytes(reply_bytes).build() + response = TelegramReader(reply, 'reply') + + return response.parameter_value + +__all__.append('TurboVACTelegramGetEntity') +class TurboVACTelegramGetEntity(TurboVACTelegramEntity): + def __init__(self, **kwargs): + TurboVACTelegramEntity.__init__(self, **kwargs) + + def on_set(self, value): + raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support set") + +__all__.append('TurboVACTelegramSetEntity') +class TurboVACTelegramSetEntity(TurboVACTelegramEntity): + def __init__(self, **kwargs): + TurboVACTelegramEntity.__init__(self, **kwargs) + + def on_get(self, value): + raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support get") diff --git a/examples/turbo_1.yaml b/examples/turbo_1.yaml new file mode 100644 index 0000000..b0737c2 --- /dev/null +++ b/examples/turbo_1.yaml @@ -0,0 +1,9 @@ +name: Turbo1 +module: EthernetUSSService +socket_timeout: 10 +socket_info: ("10.93.130.53", 10001) # astro-nautilus1 +endpoints: + - name: turbo1_converter_type + module: TurboVACTelegramEntity + number: 1 + index: 0 diff --git a/setup.py b/setup.py index 3950248..34bcc70 100644 --- a/setup.py +++ b/setup.py @@ -7,4 +7,5 @@ name="dragonfly", version='v2.1.0', # TODO: should get version from git packages=packages, + include_package_data=True, )