From 873452360439a8e6cdca0ad1314d7c47b8c18cc9 Mon Sep 17 00:00:00 2001 From: "Walter C. Pettus" Date: Thu, 3 Oct 2024 21:26:01 -0400 Subject: [PATCH 01/41] Initial version of ModbusTCP service and entities --- dripline/extensions/modbus/__init__.py | 3 + .../modbus/ethernet_modbus_service.py | 127 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 dripline/extensions/modbus/__init__.py create mode 100644 dripline/extensions/modbus/ethernet_modbus_service.py diff --git a/dripline/extensions/modbus/__init__.py b/dripline/extensions/modbus/__init__.py new file mode 100644 index 00000000..dc2295b5 --- /dev/null +++ b/dripline/extensions/modbus/__init__.py @@ -0,0 +1,3 @@ +__all__ = [] + +from .ethernet_modbus_service import * diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py new file mode 100644 index 00000000..51e014da --- /dev/null +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -0,0 +1,127 @@ +try: + import pymodbus +except ImportError: + pass + +import scarab + +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', + byteorder='big', + **kwargs + ): + ''' + Args: + ip_address (str): properly formatted ip address of Modbus device + indexing (int, str): address indexing used by device + wordorder (str): endianness of reply words + byteorder (str): endianness of reply bytes + ''' + 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 lower(indexing) == 'plc': + self.offset = -1 + elif lower(indexing) == '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))) + + if lower(wordorder) == 'big': + self.word = pymodbus.constants.Endian.BIG + elif lower(wordorder) == 'little': + self.word = pymodbus.constants.Endian.LITTLE + else: + raise ValueError('Invalid wordorder argument <{}>, expect big or little'.format(wordorder)) + + if lower(byteorder) == 'big': + self.byte = pymodbus.constants.Endian.BIG + elif lower(byteorder) == 'little': + self.byte = pymodbus.constants.Endian.LITTLE + else: + raise ValueError('Invalid byteorder argument <{}>, expect big or little'.format(byteorder)) + + self._reconnect() + + def _reconnect(self): + ''' + Minimal connection method. + TODO: Expand to call on failed read/write, and add sophistication. + ''' + self.client = pymodbus.client.ModbusTcpClient(self.ip) + + if client.connect(): + logger.debug('Connected to Alicat Device.') + else: + raise ThrowReply('resource_error_connection','Failed to Connect to Alicat Device') + + def read_register(self, register, n_reg, reg_type=0x04): + ''' + Currently only register read type #4, read_input_registers, is implemented. + 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)) + if reg_type == 0x04: + result = self.client.read_input_registers(register+offset, n_reg) + else: + raise ThrowReply('message_error_invalid_method', 'Register type {} not supported'.format(reg_type)) + logger.info('Device returned {}'.format(result)) + return pymodbus.payload.BinaryPayloadDecoder.fromRegisters(result.registers, wordorder=self.word, byteorder=self.byte) + + def write_register(self, register, value): + if not isinstance(value, list): + raise ThrowReply('message_error_invalid_method', 'Unsupported write type') + return self.client.write_register(register+offset, value) + + +__all__.append('ModbusEntity') +class ModbusEntity(Entity): + ''' + Generic entity for Modbus read and write. + TODO: Add additional read-only or write-only versions + ''' + def __init__(self, + register, + n_reg, + data_type, + **kwargs): + ''' + ''' + self.register = register + self.n_reg = n_reg + self.data_type = data_type + Entity.__init__(self, **kwargs) + + @calibrate() + def on_get(self): + result = self.service.read_register(self.register, self.n_reg) + if self.data_type == 'float32': + result = result.decode_32bit_float() + logger.info('Decoded result is {}'.format(result)) + return result + + def on_set(self, value): + return self.service.write_register(self.register, value) From 4792a555cc63d8ea7d356137ee916076b79863b1 Mon Sep 17 00:00:00 2001 From: Robert Cabral Date: Thu, 10 Oct 2024 12:19:33 -0400 Subject: [PATCH 02/41] Fixing python errors in ethernet modbus file --- .../modbus/ethernet_modbus_service.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index 51e014da..cc4b1de7 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -1,5 +1,7 @@ try: import pymodbus + from pymodbus.client import ModbusTcpClient + from pymodbus.payload import BinaryPayloadDecoder except ImportError: pass @@ -41,25 +43,25 @@ def __init__(self, if isinstance(indexing, int): self.offset = indexing elif isinstance(indexing, str): - if lower(indexing) == 'plc': + if indexing.lower() == 'plc': self.offset = -1 - elif lower(indexing) == 'protocol': + 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))) - if lower(wordorder) == 'big': + if wordorder.lower() == 'big': self.word = pymodbus.constants.Endian.BIG - elif lower(wordorder) == 'little': + elif wordorder.lower() == 'little': self.word = pymodbus.constants.Endian.LITTLE else: raise ValueError('Invalid wordorder argument <{}>, expect big or little'.format(wordorder)) - if lower(byteorder) == 'big': + if byteorder.lower() == 'big': self.byte = pymodbus.constants.Endian.BIG - elif lower(byteorder) == 'little': + elif byteorder.lower() == 'little': self.byte = pymodbus.constants.Endian.LITTLE else: raise ValueError('Invalid byteorder argument <{}>, expect big or little'.format(byteorder)) @@ -71,9 +73,9 @@ def _reconnect(self): Minimal connection method. TODO: Expand to call on failed read/write, and add sophistication. ''' - self.client = pymodbus.client.ModbusTcpClient(self.ip) + self.client = ModbusTcpClient(self.ip) - if client.connect(): + if self.client.connect(): logger.debug('Connected to Alicat Device.') else: raise ThrowReply('resource_error_connection','Failed to Connect to Alicat Device') @@ -85,16 +87,16 @@ def read_register(self, register, n_reg, reg_type=0x04): ''' logger.debug('Reading {} registers starting with {}'.format(n_reg, register)) if reg_type == 0x04: - result = self.client.read_input_registers(register+offset, n_reg) + result = self.client.read_input_registers(register + self.offset, n_reg) else: raise ThrowReply('message_error_invalid_method', 'Register type {} not supported'.format(reg_type)) logger.info('Device returned {}'.format(result)) - return pymodbus.payload.BinaryPayloadDecoder.fromRegisters(result.registers, wordorder=self.word, byteorder=self.byte) + return BinaryPayloadDecoder.fromRegisters(result.registers, wordorder=self.word, byteorder=self.byte) def write_register(self, register, value): if not isinstance(value, list): raise ThrowReply('message_error_invalid_method', 'Unsupported write type') - return self.client.write_register(register+offset, value) + return self.client.write_register(register + self.offset, value) __all__.append('ModbusEntity') From 4322174ff907e6752ee86f75649ed5c9bf77e4d9 Mon Sep 17 00:00:00 2001 From: "Walter C. Pettus" Date: Thu, 17 Oct 2024 12:36:49 -0400 Subject: [PATCH 03/41] better modbus log information --- dripline/extensions/modbus/ethernet_modbus_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index cc4b1de7..20b65a63 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -90,7 +90,7 @@ def read_register(self, register, n_reg, reg_type=0x04): result = self.client.read_input_registers(register + self.offset, n_reg) else: raise ThrowReply('message_error_invalid_method', 'Register type {} not supported'.format(reg_type)) - logger.info('Device returned {}'.format(result)) + logger.info('Device returned {}'.format(result.registers)) return BinaryPayloadDecoder.fromRegisters(result.registers, wordorder=self.word, byteorder=self.byte) def write_register(self, register, value): @@ -122,7 +122,7 @@ def on_get(self): result = self.service.read_register(self.register, self.n_reg) if self.data_type == 'float32': result = result.decode_32bit_float() - logger.info('Decoded result is {}'.format(result)) + logger.info('Decoded result for <{}> is {}'.format(self.name, result)) return result def on_set(self, value): From b83642d99119d9a7d2e15f35240cafcecfb13dbb Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Tue, 27 May 2025 18:21:07 +0200 Subject: [PATCH 04/41] added a FormatEntity that supports asteval, in addition define an command entity that we can use e.g. to degas our pressure gauges --- dripline/extensions/__init__.py | 1 + dripline/extensions/asteval_endpoint.py | 75 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 dripline/extensions/asteval_endpoint.py diff --git a/dripline/extensions/__init__.py b/dripline/extensions/__init__.py index b62df1ed..dd061d22 100644 --- a/dripline/extensions/__init__.py +++ b/dripline/extensions/__init__.py @@ -7,3 +7,4 @@ # Modules in this directory from .add_auth_spec import * +from .asteval_endpoint import * diff --git a/dripline/extensions/asteval_endpoint.py b/dripline/extensions/asteval_endpoint.py new file mode 100644 index 00000000..bd44fd50 --- /dev/null +++ b/dripline/extensions/asteval_endpoint.py @@ -0,0 +1,75 @@ +import asteval # used for FormatEntity +import re # used for FormatEntity + +from dripline.core import calibrate, ThrowReply +from dripline.implementations import FormatEntity +from dripline.core import Entity + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + + + +__all__.append('FormatEntityAsteval') +class FormatEntityAsteval(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_get_string="def f(response): return response", + **kwargs): + ''' + Args: + get_str (str): sent verbatim in the event of on_get; if None, getting of endpoint is disabled + get_reply_float (bool): apply special formatting to get return + set_str (str): sent as set_str.format(value) in the event of on_set; if None, setting of endpoint is disabled + set_value_lowercase (bool): default option to map all string set value to .lower() + **WARNING**: never set to False if using a set_value_map dict + set_value_map (str||dict): inverse of calibration to map raw set value to value sent; either a dictionary or an asteval-interpretable string + extract_raw_regex (str): regular expression search pattern applied to get return. Must be constructed with an extraction group keyed with the name "value_raw" (ie r'(?P)' ) + ''' + super().__init__(**kwargs) + self.asteval_get_string = asteval_get_string # has to contain a definition "def f(response): ... return value" + logger.debug(f'asteval_get_string: {repr(self.asteval_get_string)}') + self.evaluator(asteval_get_string) + + @calibrate() + def on_get(self): + if self._get_str is None: + raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support get") + result = self.service.send_to_device([self._get_str]) + logger.debug(f'result is: {result}') + if self._extract_raw_regex is not None: + first_result = result + matches = re.search(self._extract_raw_regex, first_result) + if matches is None: + logger.error('matching returned none') + # exceptions.DriplineValueError + raise ThrowReply('resource_error', 'device returned unparsable result, [{}] has no match to input regex [{}]'.format(first_result, self._extract_raw_regex)) + logger.debug(f"matches are: {matches.groupdict()}") + result = matches.groupdict()['value_raw'] + + result = result.replace('\x00', '') + + processed_result = self.evaluator(f"f('{result}')") + logger.debug(f"processed_result: {repr(processed_result)}") + return processed_result + + + +__all__.append('CmdEntity') +class CmdEntity(Entity): + def __init__(self, cmd_str=None, **kwargs): + Entity.__init__(self, **kwargs) + logger.debug(f"I get cmd_str: {cmd_str}, which is of type {type(cmd_str)}.") + self.cmd_str = cmd_str + + def cmd(self): + logger.debug("Command function was successfully called") + if self.cmd_str is None: + raise ThrowReply('service_error', f"endpoint '{self.name}' does not support cmd") + return self.service.send_to_device([self.cmd_str]) From d8b8a415694269487cdc9e21e39de78d043b080a Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Sun, 20 Jul 2025 10:24:46 +0200 Subject: [PATCH 05/41] extending documentation of functions and classes, reusing code that is already defined in FormatEntity in init and on_get function. --- dripline/extensions/asteval_endpoint.py | 30 +++++++++++-------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/dripline/extensions/asteval_endpoint.py b/dripline/extensions/asteval_endpoint.py index bd44fd50..21eca9ba 100644 --- a/dripline/extensions/asteval_endpoint.py +++ b/dripline/extensions/asteval_endpoint.py @@ -31,30 +31,17 @@ def __init__(self, **WARNING**: never set to False if using a set_value_map dict set_value_map (str||dict): inverse of calibration to map raw set value to value sent; either a dictionary or an asteval-interpretable string extract_raw_regex (str): regular expression search pattern applied to get return. Must be constructed with an extraction group keyed with the name "value_raw" (ie r'(?P)' ) + asteval_get_string (str): function definition to format response. Default: "def f(response): return response" ''' - super().__init__(**kwargs) + FormatEntity.__init__(self, **kwargs) self.asteval_get_string = asteval_get_string # has to contain a definition "def f(response): ... return value" logger.debug(f'asteval_get_string: {repr(self.asteval_get_string)}') self.evaluator(asteval_get_string) @calibrate() def on_get(self): - if self._get_str is None: - raise ThrowReply('message_error_invalid_method', f"endpoint '{self.name}' does not support get") - result = self.service.send_to_device([self._get_str]) - logger.debug(f'result is: {result}') - if self._extract_raw_regex is not None: - first_result = result - matches = re.search(self._extract_raw_regex, first_result) - if matches is None: - logger.error('matching returned none') - # exceptions.DriplineValueError - raise ThrowReply('resource_error', 'device returned unparsable result, [{}] has no match to input regex [{}]'.format(first_result, self._extract_raw_regex)) - logger.debug(f"matches are: {matches.groupdict()}") - result = matches.groupdict()['value_raw'] - - result = result.replace('\x00', '') - + result =FormatEntiry.on_get(self) + #result = result.replace('\x00', '') processed_result = self.evaluator(f"f('{result}')") logger.debug(f"processed_result: {repr(processed_result)}") return processed_result @@ -63,7 +50,16 @@ def on_get(self): __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)}.") self.cmd_str = cmd_str From 8d1f7b26d583028c06cfc1afd25a911b28eb4213 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 28 Jul 2025 11:55:07 +0200 Subject: [PATCH 06/41] Fixing the dripline version in the docker compose file --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 27455bb8..4a0d367e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,6 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python -#ARG img_tag=develop-dev -ARG img_tag=receiver-test +ARG img_tag=v5.0.0-dev FROM ${img_user}/${img_repo}:${img_tag} From 6ecb50a0f5892c0d915434d3c3ddf18bddc155cf Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 28 Jul 2025 11:55:28 +0200 Subject: [PATCH 07/41] fixing typo in name of super class --- dripline/extensions/asteval_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dripline/extensions/asteval_endpoint.py b/dripline/extensions/asteval_endpoint.py index 21eca9ba..c7959805 100644 --- a/dripline/extensions/asteval_endpoint.py +++ b/dripline/extensions/asteval_endpoint.py @@ -40,7 +40,7 @@ def __init__(self, @calibrate() def on_get(self): - result =FormatEntiry.on_get(self) + result =FormatEntity.on_get(self) #result = result.replace('\x00', '') processed_result = self.evaluator(f"f('{result}')") logger.debug(f"processed_result: {repr(processed_result)}") From e2e23c10012d03a7a8ebf7ffe3d3f6186072a692 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 28 Jul 2025 16:19:29 +0200 Subject: [PATCH 08/41] we have to format the raw value, since its reported as a dict, we have to apply the asteval function to the dict value not the dict itself. --- dripline/extensions/asteval_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dripline/extensions/asteval_endpoint.py b/dripline/extensions/asteval_endpoint.py index c7959805..7b81a7c3 100644 --- a/dripline/extensions/asteval_endpoint.py +++ b/dripline/extensions/asteval_endpoint.py @@ -41,8 +41,8 @@ def __init__(self, @calibrate() def on_get(self): result =FormatEntity.on_get(self) - #result = result.replace('\x00', '') - processed_result = self.evaluator(f"f('{result}')") + raw = result["value_raw"] + processed_result = self.evaluator(f"f('{raw}')") logger.debug(f"processed_result: {repr(processed_result)}") return processed_result From 656d2c58cb5cd749cf9c6c98ce4c1b3fd00c252d Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Tue, 29 Jul 2025 11:26:28 +0200 Subject: [PATCH 09/41] separating cmd and asteval endpoints into different files, changing FormatEntityAsteval into AstevalFormatEntity, checking of input at constructor, improving doc strings --- dripline/extensions/__init__.py | 1 + dripline/extensions/asteval_endpoint.py | 47 +++++-------------------- dripline/extensions/cmd_endpoint.py | 31 ++++++++++++++++ 3 files changed, 40 insertions(+), 39 deletions(-) create mode 100644 dripline/extensions/cmd_endpoint.py diff --git a/dripline/extensions/__init__.py b/dripline/extensions/__init__.py index 9bafdc63..f016cc80 100644 --- a/dripline/extensions/__init__.py +++ b/dripline/extensions/__init__.py @@ -8,6 +8,7 @@ # 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 * diff --git a/dripline/extensions/asteval_endpoint.py b/dripline/extensions/asteval_endpoint.py index 7b81a7c3..93df9468 100644 --- a/dripline/extensions/asteval_endpoint.py +++ b/dripline/extensions/asteval_endpoint.py @@ -3,7 +3,6 @@ from dripline.core import calibrate, ThrowReply from dripline.implementations import FormatEntity -from dripline.core import Entity import logging logger = logging.getLogger(__name__) @@ -12,31 +11,25 @@ -__all__.append('FormatEntityAsteval') -class FormatEntityAsteval(FormatEntity): +__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_get_string="def f(response): return response", + asteval_format_response_string="def f(response): return response", **kwargs): ''' Args: - get_str (str): sent verbatim in the event of on_get; if None, getting of endpoint is disabled - get_reply_float (bool): apply special formatting to get return - set_str (str): sent as set_str.format(value) in the event of on_set; if None, setting of endpoint is disabled - set_value_lowercase (bool): default option to map all string set value to .lower() - **WARNING**: never set to False if using a set_value_map dict - set_value_map (str||dict): inverse of calibration to map raw set value to value sent; either a dictionary or an asteval-interpretable string - extract_raw_regex (str): regular expression search pattern applied to get return. Must be constructed with an extraction group keyed with the name "value_raw" (ie r'(?P)' ) - asteval_get_string (str): function definition to format response. Default: "def f(response): return response" + asteval_format_response_string (str): function definition to format response. Default: "def f(response): return response" + *kwargs -> FormatEntity ''' FormatEntity.__init__(self, **kwargs) - self.asteval_get_string = asteval_get_string # has to contain a definition "def f(response): ... return value" - logger.debug(f'asteval_get_string: {repr(self.asteval_get_string)}') - self.evaluator(asteval_get_string) + 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): @@ -45,27 +38,3 @@ def on_get(self): processed_result = self.evaluator(f"f('{raw}')") logger.debug(f"processed_result: {repr(processed_result)}") return processed_result - - - -__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)}.") - self.cmd_str = cmd_str - - def cmd(self): - logger.debug("Command function was successfully called") - if self.cmd_str is None: - raise ThrowReply('service_error', f"endpoint '{self.name}' does not support cmd") - return self.service.send_to_device([self.cmd_str]) diff --git a/dripline/extensions/cmd_endpoint.py b/dripline/extensions/cmd_endpoint.py new file mode 100644 index 00000000..7524ddab --- /dev/null +++ b/dripline/extensions/cmd_endpoint.py @@ -0,0 +1,31 @@ +import re # used for FormatEntity + +from dripline.core import calibrate, ThrowReply +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("Command function was successfully called") + return self.service.send_to_device([self.cmd_str]) From 1bf48ceb2d5ebb09ed6c34dadbc552e3ed2c9290 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Tue, 29 Jul 2025 16:35:59 +0200 Subject: [PATCH 10/41] Defining Pfeiffer endpoints that use the pfeiffer telegram protocol. We assemble and the command at each get / set and disensemble / format the response corresponding to the datatype defined by pfeiffer. --- dripline/extensions/__init__.py | 1 + dripline/extensions/pfeiffer_endpoint.py | 137 +++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 dripline/extensions/pfeiffer_endpoint.py diff --git a/dripline/extensions/__init__.py b/dripline/extensions/__init__.py index e9cb836c..8582afc5 100644 --- a/dripline/extensions/__init__.py +++ b/dripline/extensions/__init__.py @@ -10,3 +10,4 @@ from .add_auth_spec import * from .thermo_fisher_endpoint import * from .ethernet_thermo_fisher_service import * +from .pfeiffer_endpoint import * diff --git a/dripline/extensions/pfeiffer_endpoint.py b/dripline/extensions/pfeiffer_endpoint.py new file mode 100644 index 00000000..289ded9d --- /dev/null +++ b/dripline/extensions/pfeiffer_endpoint.py @@ -0,0 +1,137 @@ +from dripline.core import Entity, calibrate, ThrowReply + +import logging +logger = logging.getLogger(__name__) + +__all__ = [] + +__all__.append('PfeifferEntity') +class PfeifferEntity(Entity): + ''' + ''' + + _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, allwoed 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}" + + 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) + + def disensemble_result(self, reply): + if self.get_checksum(reply[:-3]) != int(reply[-3:]): + logger.debug("checksum not matching") + 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") From 71c4d772cdc382b1385754de0a02a6d387faafe5 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Tue, 29 Jul 2025 16:37:12 +0200 Subject: [PATCH 11/41] example config file to show how Pfeiffer Endpoints are used --- examples/pressure_gauge_60.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 examples/pressure_gauge_60.yaml 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 From fcecc095dbc1f2a05f706dde20589cd48226c837 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Tue, 29 Jul 2025 16:50:46 +0200 Subject: [PATCH 12/41] improve doc strings --- dripline/extensions/pfeiffer_endpoint.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dripline/extensions/pfeiffer_endpoint.py b/dripline/extensions/pfeiffer_endpoint.py index 289ded9d..e71fca5d 100644 --- a/dripline/extensions/pfeiffer_endpoint.py +++ b/dripline/extensions/pfeiffer_endpoint.py @@ -8,6 +8,9 @@ __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} From 9affd8c50a326b36a5a6122901503ada9d3e6d92 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Wed, 30 Jul 2025 10:09:06 +0200 Subject: [PATCH 13/41] using develop-dev as base container --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 27455bb8..1ff0f104 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,6 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python -#ARG img_tag=develop-dev -ARG img_tag=receiver-test +ARG img_tag=develop-dev FROM ${img_user}/${img_repo}:${img_tag} From e18c304d9b6439e6ef6d26410d560eac100e1c3e Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Tue, 2 Sep 2025 15:36:37 +0200 Subject: [PATCH 14/41] adding new extension service and entity to talk with Huber devices --- dripline/extensions/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dripline/extensions/__init__.py b/dripline/extensions/__init__.py index e9cb836c..90d905c2 100644 --- a/dripline/extensions/__init__.py +++ b/dripline/extensions/__init__.py @@ -10,3 +10,4 @@ from .add_auth_spec import * from .thermo_fisher_endpoint import * from .ethernet_thermo_fisher_service import * +from .ethernet_huber_service import * From cccff876d9730ffea42fdbe4a5b5485a99bbfb9b Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Tue, 2 Sep 2025 15:37:02 +0200 Subject: [PATCH 15/41] adding Huber service and entity --- dripline/extensions/ethernet_huber_service.py | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100755 dripline/extensions/ethernet_huber_service.py diff --git a/dripline/extensions/ethernet_huber_service.py b/dripline/extensions/ethernet_huber_service.py new file mode 100755 index 00000000..3ef1edf3 --- /dev/null +++ b/dripline/extensions/ethernet_huber_service.py @@ -0,0 +1,134 @@ +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 thermo fisher 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, data): + return data + + + 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 command in commands: + command = self._assemble_cmd(command) + 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) + all_data.append(data) + return all_data + + +__all__.append("HuberEntity") +class HuberEntity(Entity): + ''' + A endpoint of a Huber device that returns the request result + ''' + + def __init__(self, + get_str=None, + **kwargs): + ''' + Args: + get_str: hexstring of the command, e.g. 20 + ''' + if get_str is None: + raise ValueError(' Date: Tue, 2 Sep 2025 18:25:43 +0200 Subject: [PATCH 16/41] adding the extraction of the reply. This is partly entity dependent and thus split in a general extraction and the entity specific conversion --- dripline/extensions/ethernet_huber_service.py | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/dripline/extensions/ethernet_huber_service.py b/dripline/extensions/ethernet_huber_service.py index 3ef1edf3..68f0bdbe 100755 --- a/dripline/extensions/ethernet_huber_service.py +++ b/dripline/extensions/ethernet_huber_service.py @@ -66,9 +66,16 @@ def _assemble_cmd(self, cmd_in): cmd = cmd + cs + self.command_terminator return cmd - def _extract_reply(self, data): - return data - + 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): ''' @@ -79,8 +86,8 @@ def _send_commands(self, commands): ''' all_data=[] - for command in commands: - command = self._assemble_cmd(command) + 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: @@ -96,7 +103,7 @@ def _send_commands(self, commands): 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) + data = self._extract_reply(data, cmd.split(" ")[0]) all_data.append(data) return all_data @@ -109,6 +116,9 @@ class HuberEntity(Entity): def __init__(self, get_str=None, + offset=0, + nbytes=-1, + numeric=False, **kwargs): ''' Args: @@ -117,7 +127,10 @@ def __init__(self, if get_str is None: raise ValueError(' int("7FFF", 16): + val = val - int("FFFF", 16) - 1 + result = val / 100. return result def on_set(self, value): From 0b7b74d9f6017cf3ceb6ab37b1d7027ba9e1b3ad Mon Sep 17 00:00:00 2001 From: Robert Cabral Date: Thu, 18 Sep 2025 15:54:15 -0400 Subject: [PATCH 17/41] Expanded to include other code types --- Dockerfile | 25 +- .../modbus/ethernet_modbus_service.py | 289 ++++++++++-------- 2 files changed, 173 insertions(+), 141 deletions(-) diff --git a/Dockerfile b/Dockerfile index edf16167..8dbfb88e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,13 @@ -ARG img_user=ghcr.io/driplineorg -ARG img_repo=dripline-python -ARG img_tag=new-auth - -FROM ${img_user}/${img_repo}:${img_tag} - -COPY . /usr/local/src/dragonfly - -WORKDIR /usr/local/src/dragonfly -RUN pip install . - -WORKDIR / +ARG img_user=ghcr.io/driplineorg +ARG img_repo=dripline-python +ARG img_tag=develop + +FROM ${img_user}/${img_repo}:${img_tag} + +RUN pip install pymodbus slack_sdk +COPY . /usr/local/src/dragonfly + +WORKDIR /usr/local/src/dragonfly +RUN pip install . + +WORKDIR / diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index 20b65a63..6ec8b5a0 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -1,129 +1,160 @@ -try: - import pymodbus - from pymodbus.client import ModbusTcpClient - from pymodbus.payload import BinaryPayloadDecoder -except ImportError: - pass - -import scarab - -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', - byteorder='big', - **kwargs - ): - ''' - Args: - ip_address (str): properly formatted ip address of Modbus device - indexing (int, str): address indexing used by device - wordorder (str): endianness of reply words - byteorder (str): endianness of reply bytes - ''' - 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))) - - if wordorder.lower() == 'big': - self.word = pymodbus.constants.Endian.BIG - elif wordorder.lower() == 'little': - self.word = pymodbus.constants.Endian.LITTLE - else: - raise ValueError('Invalid wordorder argument <{}>, expect big or little'.format(wordorder)) - - if byteorder.lower() == 'big': - self.byte = pymodbus.constants.Endian.BIG - elif byteorder.lower() == 'little': - self.byte = pymodbus.constants.Endian.LITTLE - else: - raise ValueError('Invalid byteorder argument <{}>, expect big or little'.format(byteorder)) - - self._reconnect() - - def _reconnect(self): - ''' - Minimal connection method. - TODO: Expand to call on failed read/write, and add sophistication. - ''' - self.client = ModbusTcpClient(self.ip) - - if self.client.connect(): - logger.debug('Connected to Alicat Device.') - else: - raise ThrowReply('resource_error_connection','Failed to Connect to Alicat Device') - - def read_register(self, register, n_reg, reg_type=0x04): - ''' - Currently only register read type #4, read_input_registers, is implemented. - 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)) - if reg_type == 0x04: - result = self.client.read_input_registers(register + self.offset, n_reg) - else: - raise ThrowReply('message_error_invalid_method', 'Register type {} not supported'.format(reg_type)) - logger.info('Device returned {}'.format(result.registers)) - return BinaryPayloadDecoder.fromRegisters(result.registers, wordorder=self.word, byteorder=self.byte) - - def write_register(self, register, value): - if not isinstance(value, list): - raise ThrowReply('message_error_invalid_method', 'Unsupported write type') - return self.client.write_register(register + self.offset, value) - - -__all__.append('ModbusEntity') -class ModbusEntity(Entity): - ''' - Generic entity for Modbus read and write. - TODO: Add additional read-only or write-only versions - ''' - def __init__(self, - register, - n_reg, - data_type, - **kwargs): - ''' - ''' - self.register = register - self.n_reg = n_reg - self.data_type = data_type - Entity.__init__(self, **kwargs) - - @calibrate() - def on_get(self): - result = self.service.read_register(self.register, self.n_reg) - if self.data_type == 'float32': - result = result.decode_32bit_float() - logger.info('Decoded result for <{}> is {}'.format(self.name, result)) - return result - - def on_set(self, value): - return self.service.write_register(self.register, value) +try: + import pymodbus + from pymodbus.client import ModbusTcpClient + from pymodbus.payload import BinaryPayloadDecoder +except ImportError: + pass + +import scarab + +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', + byteorder='big', + **kwargs + ): + ''' + Args: + ip_address (str): properly formatted ip address of Modbus device + indexing (int, str): address indexing used by device + wordorder (str): endianness of reply words + byteorder (str): endianness of reply bytes + ''' + 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))) + + if wordorder.lower() == 'big': + self.word = pymodbus.constants.Endian.BIG + elif wordorder.lower() == 'little': + self.word = pymodbus.constants.Endian.LITTLE + else: + raise ValueError('Invalid wordorder argument <{}>, expect big or little'.format(wordorder)) + + if byteorder.lower() == 'big': + self.byte = pymodbus.constants.Endian.BIG + elif byteorder.lower() == 'little': + self.byte = pymodbus.constants.Endian.LITTLE + else: + raise ValueError('Invalid byteorder argument <{}>, expect big or little'.format(byteorder)) + + self.client = ModbusTcpClient(self.ip) + self._reconnect() + + def _reconnect(self): + ''' + Minimal connection method. + TODO: Expand to call on failed read/write, and add sophistication. + ''' + if self.client.connected: + self.client.close() + + if self.client.connect(): + logger.debug('Connected to Alicat Device.') + else: + raise ThrowReply('resource_error_connection','Failed to Connect to Alicat Device') + + 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: + if reg_type == 0x03: + result = self.client.read_holding_registers(register + self.offset, n_reg) + elif reg_type == 0x04: + result = self.client.read_input_registers(register + self.offset, n_reg) + + logger.info('Device returned {}'.format(result.registers)) + + except Exception as e: + logger.debug(f'read registers failed: {e}. Attempting reconnect.') + self._reconnect() + + if n_reg == 1: + return result[0] + else: + return BinaryPayloadDecoder.fromRegisters(result.registers, wordorder=self.word, byteorder=self.byte) + + def write_register(self, register, value): + ''' + This register only works with reg_type = 0x10 + ''' + logger.debug('writing {} to register {}'.format(value, register)) + + if not isinstance(value, list): + raise ThrowReply('message_error_invalid_method', 'Unsupported write type') + + try: + return self.client.write_registers(register + self.offset, value) + + except Exception as e: + logger.debug(f'write_registers failed: {e}. Attempting reconnect.') + self._reconnect() + return self.client.write_registers(register + self.offset, value) + + +__all__.append('ModbusEntity') +class ModbusEntity(Entity): + ''' + Generic entity for Modbus read and write. + TODO: Add additional read-only or write-only versions + ''' + def __init__(self, + register, + n_reg = 1, + data_type = None, + **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 + ''' + self.register = register + self.n_reg = n_reg + self.data_type = data_type + Entity.__init__(self, **kwargs) + + @calibrate() + def on_get(self): + result = self.service.read_register(self.register, self.n_reg) + if self.data_type == 'float32': + result = result.decode_32bit_float() + logger.info('Decoded result for <{}> is {}'.format(self.name, result)) + return result + + def on_set(self, value): + return self.service.write_register(self.register, value) From bb0a5bb038857e3fd21bb89bb2b2867c190de2d3 Mon Sep 17 00:00:00 2001 From: Robert Cabral Date: Tue, 30 Sep 2025 19:47:40 -0400 Subject: [PATCH 18/41] Fixed Dockerfile slack_sdk error --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8dbfb88e..e99dfcca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG img_tag=develop FROM ${img_user}/${img_repo}:${img_tag} -RUN pip install pymodbus slack_sdk +RUN pip install pymodbus COPY . /usr/local/src/dragonfly WORKDIR /usr/local/src/dragonfly From 428268f4422cf187ceee1648b7766b6a828dc5a1 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 6 Oct 2025 15:10:13 +0200 Subject: [PATCH 19/41] update to recent dripline version --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 27455bb8..c4a291f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,6 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python -#ARG img_tag=develop-dev -ARG img_tag=receiver-test +ARG img_tag=v5.1.0 FROM ${img_user}/${img_repo}:${img_tag} From 5e90e9fe64ec892cad08df74787e8516289ab13c Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 6 Oct 2025 15:10:51 +0200 Subject: [PATCH 20/41] separate conversion function to float in its own function --- dripline/extensions/ethernet_huber_service.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/dripline/extensions/ethernet_huber_service.py b/dripline/extensions/ethernet_huber_service.py index 68f0bdbe..f77ce2b2 100755 --- a/dripline/extensions/ethernet_huber_service.py +++ b/dripline/extensions/ethernet_huber_service.py @@ -133,6 +133,12 @@ def __init__(self, self.numeric = numeric Entity.__init__(self, **kwargs) + def convert_to_float(self, hex_str): + val = int(hex_str, 16) + if val > int("7FFF", 16): + val = val - int("FFFF", 16) - 1 + return val/100. + @calibrate() def on_get(self): # setup cmd here @@ -142,10 +148,9 @@ def on_get(self): logger.debug(f'raw result is: {result}') result = result[self.offset: self.offset+self.nbytes] if self.numeric: - val = int(result, 16) - if val > int("7FFF", 16): - val = val - int("FFFF", 16) - 1 - result = val / 100. + logger.debug("is numeric") + result = self.convert_to_float(result) + logger.debug(f'extracted result is: {result}') return result def on_set(self, value): From ced66d133a436d23c68703b02fc5b19302c62171 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Mon, 6 Oct 2025 15:12:25 +0200 Subject: [PATCH 21/41] update to recent dripline version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1ff0f104..c4a291f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python -ARG img_tag=develop-dev +ARG img_tag=v5.1.0 FROM ${img_user}/${img_repo}:${img_tag} From de6ab642b505da663647ffbe6061742bd541fb6e Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 19 Dec 2025 15:38:11 +0100 Subject: [PATCH 22/41] replacing depricated BinaryPayloadDecoder by convert_from_register, by this the byteorder argument gets oppsolate and the wordorder argument has to be part of the entity not the service. Service now returns the register bytes and entity has to decode them. --- .../modbus/ethernet_modbus_service.py | 60 ++++++++----------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index 6ec8b5a0..0a0534d2 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -1,7 +1,6 @@ try: import pymodbus from pymodbus.client import ModbusTcpClient - from pymodbus.payload import BinaryPayloadDecoder except ImportError: pass @@ -23,16 +22,12 @@ class EthernetModbusService(Service): def __init__(self, ip_address, indexing='protocol', - wordorder='big', - byteorder='big', **kwargs ): ''' Args: ip_address (str): properly formatted ip address of Modbus device indexing (int, str): address indexing used by device - wordorder (str): endianness of reply words - byteorder (str): endianness of reply bytes ''' if not 'pymodbus' in globals(): raise ImportError('pymodbus not found, required for EthernetModbusService class') @@ -52,35 +47,20 @@ def __init__(self, else: raise TypeError('Invalid indexing type <{}>, expect string or int'.format(type(indexing))) - if wordorder.lower() == 'big': - self.word = pymodbus.constants.Endian.BIG - elif wordorder.lower() == 'little': - self.word = pymodbus.constants.Endian.LITTLE - else: - raise ValueError('Invalid wordorder argument <{}>, expect big or little'.format(wordorder)) - - if byteorder.lower() == 'big': - self.byte = pymodbus.constants.Endian.BIG - elif byteorder.lower() == 'little': - self.byte = pymodbus.constants.Endian.LITTLE - else: - raise ValueError('Invalid byteorder argument <{}>, expect big or little'.format(byteorder)) - self.client = ModbusTcpClient(self.ip) self._reconnect() def _reconnect(self): ''' Minimal connection method. - TODO: Expand to call on failed read/write, and add sophistication. ''' if self.client.connected: self.client.close() if self.client.connect(): - logger.debug('Connected to Alicat Device.') + logger.debug('Connected to Device.') else: - raise ThrowReply('resource_error_connection','Failed to Connect to Alicat Device') + raise ThrowReply('resource_error_connection','Failed to Connect to Device') def read_register(self, register, n_reg, reg_type=0x04): ''' @@ -93,9 +73,9 @@ def read_register(self, register, n_reg, reg_type=0x04): try: if reg_type == 0x03: - result = self.client.read_holding_registers(register + self.offset, n_reg) + 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, n_reg) + result = self.client.read_input_registers(register + self.offset, count=n_reg) logger.info('Device returned {}'.format(result.registers)) @@ -104,27 +84,31 @@ def read_register(self, register, n_reg, reg_type=0x04): self._reconnect() if n_reg == 1: - return result[0] + return result.registers[0] else: - return BinaryPayloadDecoder.fromRegisters(result.registers, wordorder=self.word, byteorder=self.byte) + return result.registers def write_register(self, register, value): ''' - This register only works with reg_type = 0x10 + This register uses reg_type = 0x10 if value is a list and reg_type = 0x06 otherwise. ''' logger.debug('writing {} to register {}'.format(value, register)) - if not isinstance(value, list): - raise ThrowReply('message_error_invalid_method', 'Unsupported write type') - try: - return self.client.write_registers(register + self.offset, value) - + if isinstance(value, list): + return self.client.write_registers(register + self.offset, value) + else: + return self.client.write_register(register + self.offset, value) except Exception as e: logger.debug(f'write_registers failed: {e}. Attempting reconnect.') self._reconnect() - return self.client.write_registers(register + self.offset, value) - + try: + if isinstance(value, list): + return self.client.write_registers(register + self.offset, value) + else: + return self.client.write_register(register + self.offset, value) + except: + raise ThrowReply('resource_error_write','Failed to write register') __all__.append('ModbusEntity') class ModbusEntity(Entity): @@ -135,7 +119,9 @@ class ModbusEntity(Entity): def __init__(self, register, n_reg = 1, + wordorder = "big", data_type = None, + reg_type = 0x04, **kwargs): ''' Args: @@ -145,14 +131,16 @@ def __init__(self, ''' self.register = register self.n_reg = n_reg + self.reg_type = reg_type + self.wordorder = wordorder self.data_type = data_type Entity.__init__(self, **kwargs) @calibrate() def on_get(self): - result = self.service.read_register(self.register, self.n_reg) + result = self.service.read_register(self.register, self.n_reg, self.reg_type) if self.data_type == 'float32': - result = result.decode_32bit_float() + result = ModbusTcpClient.convert_from_registers(result, ModbusTcpClient.DATATYPE.FLOAT32, word_order=self.wordorder) logger.info('Decoded result for <{}> is {}'.format(self.name, result)) return result From 3d168eeb6dfa4a6485a56f2d44825ff27dc0c37c Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 19 Dec 2025 16:13:32 +0100 Subject: [PATCH 23/41] return register(s) not the modbus object itself. Differ between array and single value. --- dripline/extensions/modbus/ethernet_modbus_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index 0a0534d2..3cd38b74 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -96,17 +96,17 @@ def write_register(self, register, value): try: if isinstance(value, list): - return self.client.write_registers(register + self.offset, value) + return self.client.write_registers(register + self.offset, value).registers else: - return self.client.write_register(register + self.offset, value) + return self.client.write_register(register + self.offset, value).registers[0] except Exception as e: logger.debug(f'write_registers failed: {e}. Attempting reconnect.') self._reconnect() try: if isinstance(value, list): - return self.client.write_registers(register + self.offset, value) + return self.client.write_registers(register + self.offset, value).registers else: - return self.client.write_register(register + self.offset, value) + return self.client.write_register(register + self.offset, value).registers[0] except: raise ThrowReply('resource_error_write','Failed to write register') From 1ec9dc8a673b2ddc7efaeb5e252a20201f891a38 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 19 Dec 2025 16:34:42 +0100 Subject: [PATCH 24/41] do a second try after reconnect if query fails --- dripline/extensions/modbus/ethernet_modbus_service.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index 3cd38b74..0134339b 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -82,6 +82,17 @@ def read_register(self, register, n_reg, reg_type=0x04): except Exception as e: logger.debug(f'read registers failed: {e}. Attempting reconnect.') self._reconnect() + try: + 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)) + + except Exception as e: + raise ThrowReply('resource_error_query', 'Query data failed') + if n_reg == 1: return result.registers[0] From 692661793936ced242dc0135fe39dba442ba0c35 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 19 Dec 2025 16:35:47 +0100 Subject: [PATCH 25/41] implement type conversion for any datatype provided by pymodbus. Also apply type conversion to set method --- .../modbus/ethernet_modbus_service.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index 0134339b..db67e82e 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -125,8 +125,21 @@ def write_register(self, register, value): class ModbusEntity(Entity): ''' Generic entity for Modbus read and write. - TODO: Add additional read-only or write-only versions ''' + + 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, @@ -150,10 +163,12 @@ def __init__(self, @calibrate() def on_get(self): result = self.service.read_register(self.register, self.n_reg, self.reg_type) - if self.data_type == 'float32': - result = ModbusTcpClient.convert_from_registers(result, ModbusTcpClient.DATATYPE.FLOAT32, word_order=self.wordorder) + if self.data_type in self.dtype_map: + result = ModbusTcpClient.convert_from_registers(result, self.dtype_map[self.data_type], word_order=self.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.wordorder) return self.service.write_register(self.register, value) From cae9183d8f14fed2e8418ceca06b84fdff7ed349 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 19 Dec 2025 16:36:21 +0100 Subject: [PATCH 26/41] add doc string for parameters --- dripline/extensions/modbus/ethernet_modbus_service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index db67e82e..a272c62e 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -151,7 +151,9 @@ def __init__(self, Args: register (int): address to read from n_reg (int): number of registers needed to read + wordorder (["big", "littel"]) 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 From b677db69f3642ec12605036cde720fad3a0a3838 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 19 Dec 2025 16:36:41 +0100 Subject: [PATCH 27/41] adding only get and set entities --- .../modbus/ethernet_modbus_service.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/modbus/ethernet_modbus_service.py index a272c62e..c96211c9 100644 --- a/dripline/extensions/modbus/ethernet_modbus_service.py +++ b/dripline/extensions/modbus/ethernet_modbus_service.py @@ -174,3 +174,26 @@ 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.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") From 7a578b1500fa90e9927c867019a1a372017834b6 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 19 Dec 2025 16:52:14 +0100 Subject: [PATCH 28/41] changing __init__ --- dripline/extensions/__init__.py | 22 +++++-------------- .../{modbus => }/ethernet_modbus_service.py | 0 dripline/extensions/modbus/__init__.py | 3 --- 3 files changed, 5 insertions(+), 20 deletions(-) rename dripline/extensions/{modbus => }/ethernet_modbus_service.py (100%) delete mode 100644 dripline/extensions/modbus/__init__.py diff --git a/dripline/extensions/__init__.py b/dripline/extensions/__init__.py index 48f5d093..d0a4e923 100644 --- a/dripline/extensions/__init__.py +++ b/dripline/extensions/__init__.py @@ -1,21 +1,9 @@ +__all__ = [] + __path__ = __import__('pkgutil').extend_path(__path__, __name__) +# Subdirectories from . import jitter -# add further subdirectories here - -import logging -logger = logging.getLogger(__name__) -def __get_version(): - import scarab - import dragonfly - import pkg_resources - #TODO: this all needs to be populated from setup.py and gita - version = scarab.VersionSemantic() - logger.info('version should be: {}'.format(pkg_resources.get_distribution('dragonfly').version)) - version.parse(pkg_resources.get_distribution('dragonfly').version) - version.package = 'project8/dragonfly' - version.commit = 'na' - dragonfly.core.add_version('dragonfly', version) - return version -version = __get_version() +# Modules in this directory +from .ethernet_modbus_service import * diff --git a/dripline/extensions/modbus/ethernet_modbus_service.py b/dripline/extensions/ethernet_modbus_service.py similarity index 100% rename from dripline/extensions/modbus/ethernet_modbus_service.py rename to dripline/extensions/ethernet_modbus_service.py diff --git a/dripline/extensions/modbus/__init__.py b/dripline/extensions/modbus/__init__.py deleted file mode 100644 index dc2295b5..00000000 --- a/dripline/extensions/modbus/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__all__ = [] - -from .ethernet_modbus_service import * From d98f9d0e8dc704c667ed60ea4ce0cb6dc1e5d189 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Thu, 22 Jan 2026 11:23:58 +0100 Subject: [PATCH 29/41] updating dripline-python version to v5.1.2 to get ride of quill error --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a4cfe904..40a879bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python -ARG img_tag=v5.1.0 +ARG img_tag=v5.1.2 FROM ${img_user}/${img_repo}:${img_tag} From ae59856b31f619af70191a00cfbfec897b95d6a5 Mon Sep 17 00:00:00 2001 From: "Walter C. Pettus" Date: Thu, 22 Jan 2026 15:18:11 -0500 Subject: [PATCH 30/41] Cleaning imports --- dripline/extensions/asteval_endpoint.py | 5 +---- dripline/extensions/cmd_endpoint.py | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/dripline/extensions/asteval_endpoint.py b/dripline/extensions/asteval_endpoint.py index 93df9468..68e134b7 100644 --- a/dripline/extensions/asteval_endpoint.py +++ b/dripline/extensions/asteval_endpoint.py @@ -1,7 +1,4 @@ -import asteval # used for FormatEntity -import re # used for FormatEntity - -from dripline.core import calibrate, ThrowReply +from dripline.core import calibrate from dripline.implementations import FormatEntity import logging diff --git a/dripline/extensions/cmd_endpoint.py b/dripline/extensions/cmd_endpoint.py index 7524ddab..2897f184 100644 --- a/dripline/extensions/cmd_endpoint.py +++ b/dripline/extensions/cmd_endpoint.py @@ -1,6 +1,3 @@ -import re # used for FormatEntity - -from dripline.core import calibrate, ThrowReply from dripline.core import Entity import logging From 1848063f54037fd34cbb124c2ab9a24c3858896f Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 23 Jan 2026 09:24:17 +0100 Subject: [PATCH 31/41] updating dripline version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c4a291f5..9e9df5cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python -ARG img_tag=v5.1.0 +ARG img_tag=v5.1.2 FROM ${img_user}/${img_repo}:${img_tag} From 6b1d0ab9996067bbcf33ac177846fe99a768c04e Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 23 Jan 2026 09:39:17 +0100 Subject: [PATCH 32/41] updating dripline-python version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c4a291f5..9e9df5cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG img_user=ghcr.io/driplineorg ARG img_repo=dripline-python -ARG img_tag=v5.1.0 +ARG img_tag=v5.1.2 FROM ${img_user}/${img_repo}:${img_tag} From 58cb1a00e4f48de806731bd758bf4d97727a25ef Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 23 Jan 2026 10:27:35 +0100 Subject: [PATCH 33/41] remove unused import and factor out doublicate read and write code for individual read and write attempts --- .../extensions/ethernet_modbus_service.py | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/dripline/extensions/ethernet_modbus_service.py b/dripline/extensions/ethernet_modbus_service.py index c96211c9..fff905fa 100644 --- a/dripline/extensions/ethernet_modbus_service.py +++ b/dripline/extensions/ethernet_modbus_service.py @@ -4,8 +4,6 @@ except ImportError: pass -import scarab - from dripline.core import calibrate, Entity, Service, ThrowReply import logging @@ -62,6 +60,18 @@ def _reconnect(self): 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. @@ -72,24 +82,12 @@ def read_register(self, register, n_reg, reg_type=0x04): logger.debug('Reading {} registers starting with {}'.format(n_reg, register)) try: - 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)) - + 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: - 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)) - + result = self._read_register_attempt(register, n_reg, reg_type) except Exception as e: raise ThrowReply('resource_error_query', 'Query data failed') @@ -99,6 +97,14 @@ def read_register(self, register, n_reg, reg_type=0x04): 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. @@ -106,18 +112,12 @@ def write_register(self, register, value): logger.debug('writing {} to register {}'.format(value, register)) try: - if isinstance(value, list): - return self.client.write_registers(register + self.offset, value).registers - else: - return self.client.write_register(register + self.offset, value).registers[0] + self._write_register_attempt(register, value) except Exception as e: logger.debug(f'write_registers failed: {e}. Attempting reconnect.') self._reconnect() try: - if isinstance(value, list): - return self.client.write_registers(register + self.offset, value).registers - else: - return self.client.write_register(register + self.offset, value).registers[0] + self._write_register_attempt(register, value) except: raise ThrowReply('resource_error_write','Failed to write register') From afe73bcefcc471852c332259c7f76f5874247735 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 23 Jan 2026 10:35:42 +0100 Subject: [PATCH 34/41] making the wordorder again a service property and not a per endpoint property --- dripline/extensions/ethernet_modbus_service.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dripline/extensions/ethernet_modbus_service.py b/dripline/extensions/ethernet_modbus_service.py index fff905fa..54d64460 100644 --- a/dripline/extensions/ethernet_modbus_service.py +++ b/dripline/extensions/ethernet_modbus_service.py @@ -20,12 +20,14 @@ class EthernetModbusService(Service): 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') @@ -45,6 +47,7 @@ def __init__(self, else: raise TypeError('Invalid indexing type <{}>, expect string or int'.format(type(indexing))) + self.wordorder = wordorder self.client = ModbusTcpClient(self.ip) self._reconnect() @@ -143,7 +146,6 @@ class ModbusEntity(Entity): def __init__(self, register, n_reg = 1, - wordorder = "big", data_type = None, reg_type = 0x04, **kwargs): @@ -151,14 +153,12 @@ def __init__(self, Args: register (int): address to read from n_reg (int): number of registers needed to read - wordorder (["big", "littel"]) 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.wordorder = wordorder self.data_type = data_type Entity.__init__(self, **kwargs) @@ -166,13 +166,13 @@ def __init__(self, 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.wordorder) + 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.wordorder) + 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') From 3c76b77a10cc7b548906364c10af229c89f7dd33 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 23 Jan 2026 11:12:08 +0100 Subject: [PATCH 35/41] return unformated value if no datatype is matching. Comment out unused parts of the disensembled message. Nicer formarting of long dict. --- dripline/extensions/pfeiffer_endpoint.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/dripline/extensions/pfeiffer_endpoint.py b/dripline/extensions/pfeiffer_endpoint.py index e71fca5d..3a898e6f 100644 --- a/dripline/extensions/pfeiffer_endpoint.py +++ b/dripline/extensions/pfeiffer_endpoint.py @@ -13,7 +13,17 @@ class PfeifferEntity(Entity): 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} + _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, @@ -57,6 +67,7 @@ def format_value(self, value): 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"]: @@ -76,13 +87,15 @@ def unformat_value(self, value): 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.debug("checksum not matching") - address = int(reply[:3]) - action = reply[3:5] - parameter = int(reply[5:8]) + 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 From b8cfea52ab3c9f00ec279dccc67177d5e4a3c149 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Thu, 5 Feb 2026 22:25:40 +0100 Subject: [PATCH 36/41] installing python packages in one go --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 40a879bc..b31d4e77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +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 pymodbus +RUN pip install docker pymodbus RUN pip install . WORKDIR / From 914aa6314ecc29437819d0f8e4896360bb8d2159 Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Thu, 5 Feb 2026 23:03:45 +0100 Subject: [PATCH 37/41] update documentation comments, nicer fromating and better variable name --- dripline/extensions/asteval_endpoint.py | 3 +-- dripline/extensions/cmd_endpoint.py | 2 +- dripline/extensions/ethernet_huber_service.py | 8 ++++---- dripline/extensions/pfeiffer_endpoint.py | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/dripline/extensions/asteval_endpoint.py b/dripline/extensions/asteval_endpoint.py index 68e134b7..8a24f778 100644 --- a/dripline/extensions/asteval_endpoint.py +++ b/dripline/extensions/asteval_endpoint.py @@ -21,7 +21,6 @@ def __init__(self, ''' Args: asteval_format_response_string (str): function definition to format response. Default: "def f(response): return response" - *kwargs -> FormatEntity ''' FormatEntity.__init__(self, **kwargs) self.asteval_format_response_string = asteval_format_response_string # has to contain a definition "def f(response): ... return value" @@ -30,7 +29,7 @@ def __init__(self, @calibrate() def on_get(self): - result =FormatEntity.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)}") diff --git a/dripline/extensions/cmd_endpoint.py b/dripline/extensions/cmd_endpoint.py index 2897f184..731b68c7 100644 --- a/dripline/extensions/cmd_endpoint.py +++ b/dripline/extensions/cmd_endpoint.py @@ -24,5 +24,5 @@ def __init__(self, cmd_str=None, **kwargs): self.cmd_str = cmd_str def cmd(self): - logger.debug("Command function was successfully called") + 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 index f77ce2b2..952cd9eb 100755 --- a/dripline/extensions/ethernet_huber_service.py +++ b/dripline/extensions/ethernet_huber_service.py @@ -27,7 +27,7 @@ def bytes_to_ints(value): class EthernetHuberService(EthernetSCPIService): ''' - A fairly specific subclass of Service for connecting to ethernet-capable thermo fisher devices. + 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): @@ -125,9 +125,9 @@ def __init__(self, get_str: hexstring of the command, e.g. 20 ''' if get_str is None: - raise ValueError(' Date: Wed, 18 Feb 2026 15:28:18 +0100 Subject: [PATCH 38/41] Changing entity class name to HuberGetEntity since it just supports get and not set --- Dockerfile | 1 - dripline/extensions/ethernet_huber_service.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4637f98b..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} diff --git a/dripline/extensions/ethernet_huber_service.py b/dripline/extensions/ethernet_huber_service.py index 952cd9eb..5f90b994 100755 --- a/dripline/extensions/ethernet_huber_service.py +++ b/dripline/extensions/ethernet_huber_service.py @@ -108,8 +108,8 @@ def _send_commands(self, commands): return all_data -__all__.append("HuberEntity") -class HuberEntity(Entity): +__all__.append("HuberGetEntity") +class HuberGetEntity(Entity): ''' A endpoint of a Huber device that returns the request result ''' @@ -125,7 +125,7 @@ def __init__(self, get_str: hexstring of the command, e.g. 20 ''' if get_str is None: - raise ValueError(' Date: Thu, 4 Jun 2026 10:22:16 -0700 Subject: [PATCH 39/41] Bumped version to 2.2.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, ) From 0d67a7560ff21891bc956a36167e403ede2dcde3 Mon Sep 17 00:00:00 2001 From: Noah Oblath Date: Thu, 4 Jun 2026 10:22:29 -0700 Subject: [PATCH 40/41] Added stub info in changelog --- changelog.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/changelog.md b/changelog.md index cf97aff5..6103765e 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,16 @@ Types of changes: Added, Changed, Deprecated, Removed, Fixed, Security ## [Unreleased] +## [2.2.0] -- ... + +### Added + +- EthernetHuberService +- EthernetModbusService +- CmdEntity +- AstevalFormatEntity +- PfeifferEntity + ## [2.1.1] -- 2026-02-05 ### Changed From c1735803d955a6c6665cc9d9677ce58a6536747f Mon Sep 17 00:00:00 2001 From: Rene Reimann Date: Fri, 5 Jun 2026 09:36:08 +0200 Subject: [PATCH 41/41] adding description of new classes --- changelog.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index 6103765e..623a53fb 100644 --- a/changelog.md +++ b/changelog.md @@ -13,11 +13,17 @@ Types of changes: Added, Changed, Deprecated, Removed, Fixed, Security ### Added -- EthernetHuberService -- EthernetModbusService -- CmdEntity -- AstevalFormatEntity -- PfeifferEntity +- 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