diff --git a/Dockerfile b/Dockerfile index 8544abd7..5c8bdb45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,5 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python -#ARG img_tag=develop-dev ARG img_tag=v5.1.5 FROM ${img_user}/${img_repo}:${img_tag} @@ -8,7 +7,7 @@ FROM ${img_user}/${img_repo}:${img_tag} COPY . /usr/local/src_dragonfly WORKDIR /usr/local/src_dragonfly -RUN pip install docker +RUN pip install docker pymodbus RUN pip install . WORKDIR / diff --git a/changelog.md b/changelog.md index cf97aff5..623a53fb 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,22 @@ Types of changes: Added, Changed, Deprecated, Removed, Fixed, Security ## [Unreleased] +## [2.2.0] -- ... + +### Added + +- AstevalFormatEntity FormatEntity which supports asteval evaluation at formating +- CmdEntity Class providing a CMD entity, this was implemented in dripline-cpp but not in dripline-python, can be used to e.g. call a calibrate CMD +- EthernetHuberService Service implementing the communication protocol used by Huber company +- HuberGetEntity A get entity to implement communication protocol used by Huber company +- EthernetModbusService Service to implement modbus communication +- ModbusEntity Entiy for modbus communication +- ModbusGetEntity GetEntity for modbus communication +- ModbusSetEntity SetEntity for modbus communication +- PfeifferEntity Entity supporting formatting used by devices from Pfeiffer company +- PfeifferGetEntity GetEntity supporting formatting used by devices from Pfeiffer company +- PfeifferSetEntity SetEnitty supporting formatting used by devices from Pfeiffer company + ## [2.1.1] -- 2026-02-05 ### Changed diff --git a/dripline/extensions/__init__.py b/dripline/extensions/__init__.py index e9cb836c..3a8e5512 100644 --- a/dripline/extensions/__init__.py +++ b/dripline/extensions/__init__.py @@ -8,5 +8,10 @@ # Modules in this directory from .add_auth_spec import * +from .cmd_endpoint import * +from .asteval_endpoint import * from .thermo_fisher_endpoint import * from .ethernet_thermo_fisher_service import * +from .ethernet_huber_service import * +from .ethernet_modbus_service import * +from .pfeiffer_endpoint import * diff --git a/dripline/extensions/asteval_endpoint.py b/dripline/extensions/asteval_endpoint.py new file mode 100644 index 00000000..8a24f778 --- /dev/null +++ b/dripline/extensions/asteval_endpoint.py @@ -0,0 +1,36 @@ +from dripline.core import calibrate +from dripline.implementations import FormatEntity + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + + + +__all__.append('AstevalFormatEntity') +class AstevalFormatEntity(FormatEntity): + ''' + Utility Entity allowing arbitrary set and query syntax and formatting for more complicated usage cases + No assumption about SCPI communication syntax. + ''' + + def __init__(self, + asteval_format_response_string="def f(response): return response", + **kwargs): + ''' + Args: + asteval_format_response_string (str): function definition to format response. Default: "def f(response): return response" + ''' + FormatEntity.__init__(self, **kwargs) + self.asteval_format_response_string = asteval_format_response_string # has to contain a definition "def f(response): ... return value" + logger.debug(f'asteval_format_response_string: {repr(self.asteval_format_response_string)}') + self.evaluator(asteval_format_response_string) + + @calibrate() + def on_get(self): + result = FormatEntity.on_get(self) + raw = result["value_raw"] + processed_result = self.evaluator(f"f('{raw}')") + logger.debug(f"processed_result: {repr(processed_result)}") + return processed_result diff --git a/dripline/extensions/cmd_endpoint.py b/dripline/extensions/cmd_endpoint.py new file mode 100644 index 00000000..731b68c7 --- /dev/null +++ b/dripline/extensions/cmd_endpoint.py @@ -0,0 +1,28 @@ +from dripline.core import Entity + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + +__all__.append('CmdEntity') +class CmdEntity(Entity): + ''' + SCPI Entity to execute a command, instead of a get or set. + The command is given via "cmd_str" and takes no additional arguments. + This can e.g. be used to auto-calibrate, set to zero or similar device commands. + ''' + def __init__(self, cmd_str=None, **kwargs): + ''' + Args: + cmd_str (str): sent verbatim in the event of cmd(). + ''' + Entity.__init__(self, **kwargs) + logger.debug(f"I get cmd_str: {cmd_str}, which is of type {type(cmd_str)}.") + if cmd_str is None: + raise ValueError("cmd_str is required for CmdEntity") + self.cmd_str = cmd_str + + def cmd(self): + logger.debug("in cmd function") + return self.service.send_to_device([self.cmd_str]) diff --git a/dripline/extensions/ethernet_huber_service.py b/dripline/extensions/ethernet_huber_service.py new file mode 100755 index 00000000..5f90b994 --- /dev/null +++ b/dripline/extensions/ethernet_huber_service.py @@ -0,0 +1,157 @@ +import time + +from dripline.core import ThrowReply, Entity, calibrate +from dripline.implementations import EthernetSCPIService + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + +__all__.append('EthernetHuberService') + +def int_to_hexstr(value): + return hex(value)[2:].zfill(2) + +def hexstr_to_bytes(value): + return bytes.fromhex(value) + +def bytes_to_hexstr(value): + return value.hex() + +def hexstr_to_int(value): + return int(value, 16) + +def bytes_to_ints(value): + return [byte for byte in value] + +class EthernetHuberService(EthernetSCPIService): + ''' + A fairly specific subclass of Service for connecting to ethernet-capable huber devices. + In particular, devices must support a half-duplex serial communication with header information, variable length data-payload and a checksum. + ''' + def __init__(self, **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. + ''' + EthernetSCPIService.__init__(self, **kwargs) + + def calculate_checksum(self, input_string): + """ + Calculates the 1-byte checksum of the input string. + Returns the checksum as a 2-character uppercase hex string. + + :param input_string: The string to compute the checksum for + :return: Checksum as a hex string (e.g., 'C6') + """ + total = sum(ord(char) for char in input_string) + checksum = total % 256 + return f"{checksum:02X}" + + def check_checksum(self, cmd): + # calculate checksum of response except checksum and check if match checksum + return self.calculate_checksum( cmd[:-2] ) == cmd[-2:] + + + def _assemble_cmd(self, cmd_in): + cmd_raw = cmd_in.split(" ")[0] + data = " ".join(cmd_in.split(" ")[1:]) + cmd = "[M01" + cmd_raw + length = len(cmd) + len(data) + 2 + cmd = cmd + f"{length:02X}" + data + cs = self.calculate_checksum(cmd) + cmd = cmd + cs + self.command_terminator + return cmd + + def _extract_reply(self, response, cmd): + if not self.calculate_checksum(response[:-2]) == response[-2:]: + logger.warning("Checksum not matching") + if not response[:4] == "[S01": + logger.warning("Header not matching") + if not response[4] == cmd: + logger.warning("cmd is not matching") + if not int(response[5:7], 16) == len(response)-2: + logger.warning("length not matching") + return response[7:-2] + + def _send_commands(self, commands): + ''' + Take a list of commands, 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 cmd in commands: + command = self._assemble_cmd(cmd) + logger.debug(f"sending: {command.encode()}") + self.socket.send(command.encode()) + if command == self.command_terminator: + blanck_command = True + else: + blanck_command = False + + data = self._listen(blanck_command) + + if self.reply_echo_cmd: + if data.startswith(command): + data = data[len(command):] + elif not blank_command: + raise ThrowReply('device_error_connection', f'Bad ethernet query return: {data}') + logger.info(f"sync: {repr(command)} -> {repr(data)}") + data = self._extract_reply(data, cmd.split(" ")[0]) + all_data.append(data) + return all_data + + +__all__.append("HuberGetEntity") +class HuberGetEntity(Entity): + ''' + A endpoint of a Huber device that returns the request result + ''' + + def __init__(self, + get_str=None, + offset=0, + nbytes=-1, + numeric=False, + **kwargs): + ''' + Args: + get_str: hexstring of the command, e.g. 20 + ''' + if get_str is None: + raise ValueError(' int("7FFF", 16): + val = val - int("FFFF", 16) - 1 + return val/100. + + @calibrate() + def on_get(self): + # setup cmd here + to_send = [self.get_str] + logger.debug(f'Send cmd in hexstr: {to_send[0]}') + result = self.service.send_to_device(to_send) + logger.debug(f'raw result is: {result}') + result = result[self.offset: self.offset+self.nbytes] + if self.numeric: + logger.debug("is numeric") + result = self.convert_to_float(result) + logger.debug(f'extracted result is: {result}') + return result + + def on_set(self, value): + raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support set") diff --git a/dripline/extensions/ethernet_modbus_service.py b/dripline/extensions/ethernet_modbus_service.py new file mode 100644 index 00000000..54d64460 --- /dev/null +++ b/dripline/extensions/ethernet_modbus_service.py @@ -0,0 +1,199 @@ +try: + import pymodbus + from pymodbus.client import ModbusTcpClient +except ImportError: + pass + +from dripline.core import calibrate, Entity, Service, ThrowReply + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + + +__all__.append('EthernetModbusService') +class EthernetModbusService(Service): + ''' + Service for connectivity to ModbusTCP instruments built on pymodbus library. + ''' + def __init__(self, + ip_address, + indexing='protocol', + wordorder = "big", + **kwargs + ): + ''' + Args: + ip_address (str): properly formatted ip address of Modbus device + indexing (int, str): address indexing used by device + wordorder (["big", "littel"]) + ''' + if not 'pymodbus' in globals(): + raise ImportError('pymodbus not found, required for EthernetModbusService class') + + Service.__init__(self, **kwargs) + + self.ip = ip_address + if isinstance(indexing, int): + self.offset = indexing + elif isinstance(indexing, str): + if indexing.lower() == 'plc': + self.offset = -1 + elif indexing.lower() == 'protocol': + self.offset = 0 + else: + raise ValueError('Invalid indexing string argument <{}>, expect or protocol'.format(indexing)) + else: + raise TypeError('Invalid indexing type <{}>, expect string or int'.format(type(indexing))) + + self.wordorder = wordorder + self.client = ModbusTcpClient(self.ip) + self._reconnect() + + def _reconnect(self): + ''' + Minimal connection method. + ''' + if self.client.connected: + self.client.close() + + if self.client.connect(): + logger.debug('Connected to Device.') + else: + raise ThrowReply('resource_error_connection','Failed to Connect to Device') + + def _read_register_attempt(self, register, n_reg, reg_type=0x04): + result = None + + if reg_type == 0x03: + result = self.client.read_holding_registers(register + self.offset, count=n_reg) + elif reg_type == 0x04: + result = self.client.read_input_registers(register + self.offset, count=n_reg) + + logger.info('Device returned {}'.format(result.registers)) + return result + + + def read_register(self, register, n_reg, reg_type=0x04): + ''' + n_reg determines the num of registers needed to express values. More n_reg are needed for higher accuracy values. + reg_type: Lookup the endpoint code types that your device can access and specify in the code. + + Expand as desired according to other calls in https://pymodbus.readthedocs.io/en/latest/source/client.html#modbus-calls + ''' + logger.debug('Reading {} registers starting with {}'.format(n_reg, register)) + + try: + result = self._read_register_attempt(register, n_reg, reg_type) + except Exception as e: + logger.debug(f'read registers failed: {e}. Attempting reconnect.') + self._reconnect() + try: + result = self._read_register_attempt(register, n_reg, reg_type) + except Exception as e: + raise ThrowReply('resource_error_query', 'Query data failed') + + + if n_reg == 1: + return result.registers[0] + else: + return result.registers + + def _write_register_attempt(self, register, value): + response = None + if isinstance(value, list): + response = self.client.write_registers(register + self.offset, value).registers + else: + response = self.client.write_register(register + self.offset, value).registers[0] + return response + + def write_register(self, register, value): + ''' + This register uses reg_type = 0x10 if value is a list and reg_type = 0x06 otherwise. + ''' + logger.debug('writing {} to register {}'.format(value, register)) + + try: + self._write_register_attempt(register, value) + except Exception as e: + logger.debug(f'write_registers failed: {e}. Attempting reconnect.') + self._reconnect() + try: + self._write_register_attempt(register, value) + except: + raise ThrowReply('resource_error_write','Failed to write register') + +__all__.append('ModbusEntity') +class ModbusEntity(Entity): + ''' + Generic entity for Modbus read and write. + ''' + + dtype_map = {"int16": ModbusTcpClient.DATATYPE.INT16, + "uint16": ModbusTcpClient.DATATYPE.UINT16, + "int32": ModbusTcpClient.DATATYPE.INT32, + "uint32": ModbusTcpClient.DATATYPE.UINT32, + "int64": ModbusTcpClient.DATATYPE.INT64, + "uint64": ModbusTcpClient.DATATYPE.UINT64, + "float32": ModbusTcpClient.DATATYPE.FLOAT32, + "float64": ModbusTcpClient.DATATYPE.FLOAT64, + "string": ModbusTcpClient.DATATYPE.STRING, + "bits": ModbusTcpClient.DATATYPE.BITS, + } + + + def __init__(self, + register, + n_reg = 1, + data_type = None, + reg_type = 0x04, + **kwargs): + ''' + Args: + register (int): address to read from + n_reg (int): number of registers needed to read + data_type (str): the data type being read from the registers + reg_type (hex): either 0x04 for input registers or 0x03 for holding registers + ''' + self.register = register + self.n_reg = n_reg + self.reg_type = reg_type + self.data_type = data_type + Entity.__init__(self, **kwargs) + + @calibrate() + def on_get(self): + result = self.service.read_register(self.register, self.n_reg, self.reg_type) + if self.data_type in self.dtype_map: + result = ModbusTcpClient.convert_from_registers(result, self.dtype_map[self.data_type], word_order=self.service.wordorder) + logger.info('Decoded result for <{}> is {}'.format(self.name, result)) + return result + + def on_set(self, value): + if self.data_type in self.dtype_map: + value = ModbusTcpClient.convert_to_registers(value, self.dtype_map[self.data_type], word_order=self.service.wordorder) + return self.service.write_register(self.register, value) + +__all__.append('ModbusGetEntity') +class ModbusGetEntity(ModbusEntity): + ''' + Identical to ModbusEntity, but with an explicit exception if on_set is attempted + ''' + def __init__(self, **kwargs): + ModbusEntity.__init__(self, **kwargs) + + def on_set(self, valuei): + raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support set") + +__all__.append('ModbusSetEntity') +class ModbusSetEntity(ModbusEntity): + ''' + Identical to ModbusEntity, but with an explicit exception if on_get is attempted + ''' + def __init__(self, **kwargs): + ModbusEntity.__init__(self, **kwargs) + + @calibrate() + def on_get(self, valuei): + raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support set") diff --git a/dripline/extensions/pfeiffer_endpoint.py b/dripline/extensions/pfeiffer_endpoint.py new file mode 100644 index 00000000..bffd8f61 --- /dev/null +++ b/dripline/extensions/pfeiffer_endpoint.py @@ -0,0 +1,153 @@ +from dripline.core import Entity, calibrate, ThrowReply + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + +__all__.append('PfeifferEntity') +class PfeifferEntity(Entity): + ''' + Pfeiffer Entity implements the Telegram protocol by Pfeiffer. + The message contains commands in form of parameter numbers and different length data that depend on the given datatype. + Conversion of datatypes and checksum checking are implemented. + ''' + + _datatype = {"bool_old": 6, + "uint": 6, + "ureal": 6, + "string": 6, + "bool": 1, + "ushort": 3, + "uexpo": 6, + "str16": 16, + "str8": 8, + "query": 2 + } + + def __init__(self, + parameter = 303, + datatype = "uexpo", + unit_address = 1, + **kwargs): + ''' + Args: + parameter (int): number of the parameter as documented in the manual" + datatype (str): one of ["bool_old", "uint", "ureal", "string", "bool", "ushort", "uexpo", "str16", "str8" + unit_address (int): number of the unit address, allowed range: 1-16 + ''' + Entity.__init__(self, **kwargs) + self.parameter = parameter + self.datatype = datatype + self.unit_address = unit_address + + def get_checksum(self, string): + return sum([ord(c) for c in string])%256 + + def format_value(self, value): + if self.datatype == "bool_old": + return "111111" if value else "000000" + if self.datatype == "bool": + return "1" if value else "0" + if self.datatype == "string": + return f"{value:6s}" + if self.datatype == "str8": + return f"{value:8s}" + if self.datatype == "str16": + return f"{value:16s}" + if self.datatype == "uint": + return f"{value:06d}" + if self.datatype == "ushort": + return f"{value:03d}" + if self.datatype == "ureal": + val = int(value*100) + return f"{val:06d}" + if self.datatype == "uexpo": + expon = int(np.log10(val)//1) + val = int(val/10**expon*1000) + expon_mod = expon - 20 + return f"{val:04d}{expon_mod:02d}" + return value + + def unformat_value(self, value): + if value in ["NO_DEF", "_RANGE", "_LOGIC"]: + raise ValueError(f"Device responded with an error {value}") + if len(value) != self._datatype[self.datatype]: + raise ValueError(f"data does not match length of datatype") + + if self.datatype == "bool_old": + return value == "111111" + if self.datatype == "bool": + return value == 1 + if self.datatype in ["string", "str16", "str8"]: + return value + if self.datatype in ["uint", "ushort"]: + return int(value) + if self.datatype == "ureal": + return int(value)/100. + if self.datatype == "uexpo": + return int(value[:4])/1000. * 10**(int(value[4:]) - 20) + return value + + def disensemble_result(self, reply): + if self.get_checksum(reply[:-3]) != int(reply[-3:]): + logger.warning("checksum not matching") + # split message into its constituents, we do not need all of them + # address = int(reply[:3]) + # action = reply[3:5] + # parameter = int(reply[5:8]) + length = int(reply[8:10]) + data = reply[10:10+length] + return data + + @calibrate() + def on_get(self): + value = "=?" + cmd = f"{self.unit_address:03d}00{self.parameter:03d}{len(value):02d}{value}" + cs = self.get_checksum(cmd) + cmd += f"{cs:03d}" + result = self.service.send_to_device([cmd]) + logger.debug(f'raw result is: {result}') + result = self.disensemble_result(result) + logger.debug(f'disensembled result is: {result}') + result = self.unformat_value(result) + logger.debug(f'unformated result is: {result}') + return result + + def on_set(self, value): + value = self.format_value(value) + cmd = f"{self.unit_address:03d}10{self.parameter:03d}{len(value):02d}{value}" + cs = self.get_checksum(cmd) + cmd += f"{cs:03d}" + result = self.service.send_to_device([cmd]) + logger.debug(f'raw result is: {result}') + result = self.disensemble_result(result) + logger.debug(f'disensembled result is: {result}') + result = self.unformat_value(result) + logger.debug(f'unformated result is: {result}') + return result + +__all__.append('PfeifferGetEntity') +class PfeifferGetEntity(PfeifferEntity): + ''' + Identical to PfeifferEntity, but with an explicit exception if on_set is attempted + ''' + + def __init__(self, **kwargs): + PfeifferEntity.__init__(self, **kwargs) + + def on_set(self, value): + raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support set") + + +__all__.append('PfeifferSetEntity') +class PfeifferSetEntity(PfeifferEntity): + ''' + Modelled on PfeifferEntity, but with an explicit exception if on_get is attempted. + ''' + + def __init__(self, **kwargs): + PfeifferEntity.__init__(self, **kwargs) + + def on_get(self): + raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support get") diff --git a/examples/pressure_gauge_60.yaml b/examples/pressure_gauge_60.yaml new file mode 100644 index 00000000..a70c4b1e --- /dev/null +++ b/examples/pressure_gauge_60.yaml @@ -0,0 +1,28 @@ +runtime-config: + name: pressure_gauge_60 + module: EthernetSCPIService + #module: EthernetPfeifferService + socket_timeout: 5 + socket_info: ("10.93.130.113", 10001) # astro-pirate + cmd_at_reconnect: + - "0010034902=?111" # Check for model name + reconnect_test: "0011034906HPT200118" + command_terminator: "\r" # is what goes after the command sent + response_terminator: "\r" # is what goes after the command sent + endpoints: + - name: pg60_error_status + module: PfeifferGetEntity + parameter: 303 + datatype: string + log_interval: 10 + + - name: pg60_pressure_mbar + module: PfeifferEntity + parameter: 740 + datatype: uexpo + log_interval: 10 + + - name : pg60_goal_degas + module: PfeifferEntity + parameter: 40 + datatype: bool diff --git a/setup.py b/setup.py index e44eecbe..e1373c55 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,6 @@ setup( name="dragonfly", - version='v2.1.1', # TODO: should get version from git + version='v2.2.0', # TODO: should get version from git packages=packages, )