Skip to content

Commit

Permalink
Add support for more modbus connection variants (#47)
Browse files Browse the repository at this point in the history
* ✨ add support for more modbus connection variants (like rtu-over-tcp)
  • Loading branch information
M4GNV5 authored Aug 30, 2023
1 parent 7f7a3f2 commit c02482f
Show file tree
Hide file tree
Showing 3 changed files with 70 additions and 15 deletions.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 split into two: The connection variant and the framer variant using the format `<framer>-over-<connection>` or just `<connection>`.
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:
Expand Down
51 changes: 38 additions & 13 deletions modbus4mqtt/modbus_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/test_modbus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit c02482f

Please sign in to comment.