From 1aba5309f9f15ec1d0e7a0e3ad8119b439639752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20L=C3=B6w?= Date: Mon, 28 Aug 2023 12:38:12 +0200 Subject: [PATCH 1/2] :sparkles: add support for more modbus connection variants (like rtu-over-tcp) --- README.md | 10 +++++-- modbus4mqtt/modbus_interface.py | 51 ++++++++++++++++++++++++--------- tests/test_modbus.py | 24 ++++++++++++++++ 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 336fec5..759287e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ https://pypi.org/project/modbus4mqtt/ [![codecov](https://codecov.io/gh/tjhowse/modbus4mqtt/branch/master/graph/badge.svg)](https://codecov.io/gh/tjhowse/modbus4mqtt) -This is a gateway that translates between modbus TCP/IP and MQTT. +This is a gateway that translates between modbus and MQTT. The mapping of modbus registers to MQTT topics is in a simple YAML file. @@ -63,10 +63,16 @@ word_order: highlow | port | Optional | 502 | The port on the modbus device to connect to. | | update_rate | Optional | 5 | The number of seconds between polls of the modbus device. | | address_offset | Optional | 0 | This offset is applied to every register address to accommodate different Modbus addressing systems. In many Modbus devices the first register is enumerated as 1, other times 0. See section 4.4 of the Modbus spec. | -| variant | Optional | N/A | Allows variants of the ModbusTcpClient library to be used. Setting this to 'sungrow' enables support of SungrowModbusTcpClient. This library transparently decrypts the modbus comms with sungrow SH inverters running newer firmware versions. | +| variant | Optional | 'tcp' | Allows modbus variants to be specified. See below list for supported variants. | | scan_batching | Optional | 100 | Must be between 1 and 100 inclusive. Modbus read operations are more efficient in bigger batches of contiguous registers, but different devices have different limits on the size of the batched reads. This setting can also be helpful when building a modbus register map for an uncharted device. In some modbus devices a single invalid register in a read range will fail the entire read operation. By setting `scan_batching` to `1` each register will be scanned individually. This will be very inefficient and should not be used in production as it will saturate the link with many read operations. | | word_order | Optional | 'highlow' | Must be either `highlow` or `lowhigh`. This determines how multi-word values are interpreted. `highlow` means a 32-bit number at address 1 will have its high two bytes stored in register 1, and its low two bytes stored in register 2. The default is typically correct, as modbus has a big-endian memory structure, but this is not universal. | +### Modbus variants +The variant is splitted into two: The connection variant and the framer variant using the format `-over-` or just ``. +For example `rtu-over-tcp` or `ascii-over-tls`. The framer is optional allowing to simply specify `tcp`, which makes it use the default modbus-TCP framer. +Supported framer variants are: `ascii`, [`binary`](https://jamod.sourceforge.net/kb/modbus_bin.html), `rtu` and `socket`. +The following connection variants are supported: `tcp`, `udp`, `tls`, `sungrow`, with the latter one transparently decrypting traffic from sungrow SH inverters running newer firmware versions. + ### Register settings ```yaml registers: diff --git a/modbus4mqtt/modbus_interface.py b/modbus4mqtt/modbus_interface.py index 5db2baf..c74dd0c 100644 --- a/modbus4mqtt/modbus_interface.py +++ b/modbus4mqtt/modbus_interface.py @@ -7,11 +7,12 @@ # TODO: Once SungrowModbusTcpClient 0.1.7 is released, # we can remove the "<3.0.0" pymodbus restriction and this # will make sense again. - from pymodbus.client import ModbusTcpClient - from pymodbus.transaction import ModbusSocketFramer + from pymodbus.client import ModbusTcpClient, ModbusUdpClient, ModbusTlsClient + from pymodbus.transaction import ModbusAsciiFramer, ModbusBinaryFramer, ModbusRtuFramer, ModbusSocketFramer except ImportError: # Pymodbus < 3.0 - from pymodbus.client.sync import ModbusTcpClient, ModbusSocketFramer + from pymodbus.client.sync import ModbusTcpClient, ModbusUdpClient, ModbusTlsClient, \ + ModbusAsciiFramer, ModbusBinaryFramer, ModbusRtuFramer, ModbusSocketFramer from SungrowModbusTcpClient import SungrowModbusTcpClient DEFAULT_SCAN_RATE_S = 5 @@ -55,17 +56,41 @@ def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None def connect(self): # Connects to the modbus device - if self._variant == 'sungrow': - # Some later versions of the sungrow inverter firmware encrypts the payloads of - # the modbus traffic. https://github.com/rpvelloso/Sungrow-Modbus is a drop-in - # replacement for ModbusTcpClient that manages decrypting the traffic for us. - self._mb = SungrowModbusTcpClient.SungrowModbusTcpClient(host=self._ip, port=self._port, - framer=ModbusSocketFramer, timeout=1, - RetryOnEmpty=True, retries=1) + clients = { + "tcp": ModbusTcpClient, + "tls": ModbusTlsClient, + "udp": ModbusUdpClient, + "sungrow": SungrowModbusTcpClient.SungrowModbusTcpClient, + # if 'serial' modbus is required at some point, the configuration + # needs to be changed to provide file, baudrate etc. + # "serial": (ModbusSerialClient, ModbusRtuFramer), + } + framers = { + "ascii": ModbusAsciiFramer, + "binary": ModbusBinaryFramer, + "rtu": ModbusRtuFramer, + "socket": ModbusSocketFramer, + } + + if self._variant is None: + desired_framer, desired_client = None, 'tcp' + elif "-over-" in self._variant: + desired_framer, desired_client = self._variant.split('-over-') else: - self._mb = ModbusTcpClient(self._ip, self._port, - framer=ModbusSocketFramer, timeout=1, - RetryOnEmpty=True, retries=1) + desired_framer, desired_client = None, self._variant + + if desired_client not in clients: + raise ValueError("Unknown modbus client: {}".format(desired_client)) + if desired_framer is not None and desired_framer not in framers: + raise ValueError("Unknown modbus framer: {}".format(desired_framer)) + + client = clients[desired_client] + if desired_framer is None: + framer = ModbusSocketFramer + else: + framer = framers[desired_framer] + + self._mb = client(self._ip, self._port, RetryOnEmpty=True, framer=framer, retries=1, timeout=1) def add_monitor_register(self, table, addr, type='uint16'): # Accepts a modbus register and table to monitor diff --git a/tests/test_modbus.py b/tests/test_modbus.py index 61fbb55..f9a0c1e 100644 --- a/tests/test_modbus.py +++ b/tests/test_modbus.py @@ -46,6 +46,30 @@ def connect_failure(self): def throw_exception(self, addr, value, unit): raise ValueError('Oh noooo!') + def perform_variant_test(self, mock_modbus, variant, expected_framer): + mock_modbus().connect.side_effect = self.connect_success + mock_modbus().read_input_registers.side_effect = self.read_input_registers + mock_modbus().read_holding_registers.side_effect = self.read_holding_registers + + m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, variant) + m.connect() + mock_modbus.assert_called_with('1.1.1.1', 111, RetryOnEmpty=True, framer=expected_framer, retries=1, timeout=1) + + def test_connection_variants(self): + with patch('modbus4mqtt.modbus_interface.ModbusTcpClient') as mock_modbus: + self.perform_variant_test(mock_modbus, None, modbus_interface.ModbusSocketFramer) + self.perform_variant_test(mock_modbus, 'tcp', modbus_interface.ModbusSocketFramer) + self.perform_variant_test(mock_modbus, 'rtu-over-tcp', modbus_interface.ModbusRtuFramer) + with patch('modbus4mqtt.modbus_interface.ModbusUdpClient') as mock_modbus: + self.perform_variant_test(mock_modbus, 'udp', modbus_interface.ModbusSocketFramer) + self.perform_variant_test(mock_modbus, 'binary-over-udp', modbus_interface.ModbusBinaryFramer) + + m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, 'notexisiting') + self.assertRaises(ValueError, m.connect) + + m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, 'notexisiting-over-tcp') + self.assertRaises(ValueError, m.connect) + def test_connect(self): with patch('modbus4mqtt.modbus_interface.ModbusTcpClient') as mock_modbus: mock_modbus().connect.side_effect = self.connect_success From b76cd40a998f003d9a8409496eff0880295f5aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20L=C3=B6w?= Date: Thu, 31 Aug 2023 00:43:36 +0200 Subject: [PATCH 2/2] :memo: fix modbus variant wording in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 759287e..178683c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ word_order: highlow | word_order | Optional | 'highlow' | Must be either `highlow` or `lowhigh`. This determines how multi-word values are interpreted. `highlow` means a 32-bit number at address 1 will have its high two bytes stored in register 1, and its low two bytes stored in register 2. The default is typically correct, as modbus has a big-endian memory structure, but this is not universal. | ### Modbus variants -The variant is splitted into two: The connection variant and the framer variant using the format `-over-` or just ``. +The variant is split into two: The connection variant and the framer variant using the format `-over-` or just ``. For example `rtu-over-tcp` or `ascii-over-tls`. The framer is optional allowing to simply specify `tcp`, which makes it use the default modbus-TCP framer. Supported framer variants are: `ascii`, [`binary`](https://jamod.sourceforge.net/kb/modbus_bin.html), `rtu` and `socket`. The following connection variants are supported: `tcp`, `udp`, `tls`, `sungrow`, with the latter one transparently decrypting traffic from sungrow SH inverters running newer firmware versions.