Skip to content

Commit c02482f

Browse files
authored
Add support for more modbus connection variants (tjhowse#47)
* ✨ add support for more modbus connection variants (like rtu-over-tcp)
1 parent 7f7a3f2 commit c02482f

File tree

3 files changed

+70
-15
lines changed

3 files changed

+70
-15
lines changed

Diff for: README.md

+8-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ https://pypi.org/project/modbus4mqtt/
88

99
[![codecov](https://codecov.io/gh/tjhowse/modbus4mqtt/branch/master/graph/badge.svg)](https://codecov.io/gh/tjhowse/modbus4mqtt)
1010

11-
This is a gateway that translates between modbus TCP/IP and MQTT.
11+
This is a gateway that translates between modbus and MQTT.
1212

1313
The mapping of modbus registers to MQTT topics is in a simple YAML file.
1414

@@ -63,10 +63,16 @@ word_order: highlow
6363
| port | Optional | 502 | The port on the modbus device to connect to. |
6464
| update_rate | Optional | 5 | The number of seconds between polls of the modbus device. |
6565
| 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. |
66-
| 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. |
66+
| variant | Optional | 'tcp' | Allows modbus variants to be specified. See below list for supported variants. |
6767
| 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. |
6868
| 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. |
6969

70+
### Modbus variants
71+
The variant is split into two: The connection variant and the framer variant using the format `<framer>-over-<connection>` or just `<connection>`.
72+
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.
73+
Supported framer variants are: `ascii`, [`binary`](https://jamod.sourceforge.net/kb/modbus_bin.html), `rtu` and `socket`.
74+
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.
75+
7076
### Register settings
7177
```yaml
7278
registers:

Diff for: modbus4mqtt/modbus_interface.py

+38-13
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
# TODO: Once SungrowModbusTcpClient 0.1.7 is released,
88
# we can remove the "<3.0.0" pymodbus restriction and this
99
# will make sense again.
10-
from pymodbus.client import ModbusTcpClient
11-
from pymodbus.transaction import ModbusSocketFramer
10+
from pymodbus.client import ModbusTcpClient, ModbusUdpClient, ModbusTlsClient
11+
from pymodbus.transaction import ModbusAsciiFramer, ModbusBinaryFramer, ModbusRtuFramer, ModbusSocketFramer
1212
except ImportError:
1313
# Pymodbus < 3.0
14-
from pymodbus.client.sync import ModbusTcpClient, ModbusSocketFramer
14+
from pymodbus.client.sync import ModbusTcpClient, ModbusUdpClient, ModbusTlsClient, \
15+
ModbusAsciiFramer, ModbusBinaryFramer, ModbusRtuFramer, ModbusSocketFramer
1516
from SungrowModbusTcpClient import SungrowModbusTcpClient
1617

1718
DEFAULT_SCAN_RATE_S = 5
@@ -55,17 +56,41 @@ def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None
5556

5657
def connect(self):
5758
# Connects to the modbus device
58-
if self._variant == 'sungrow':
59-
# Some later versions of the sungrow inverter firmware encrypts the payloads of
60-
# the modbus traffic. https://github.com/rpvelloso/Sungrow-Modbus is a drop-in
61-
# replacement for ModbusTcpClient that manages decrypting the traffic for us.
62-
self._mb = SungrowModbusTcpClient.SungrowModbusTcpClient(host=self._ip, port=self._port,
63-
framer=ModbusSocketFramer, timeout=1,
64-
RetryOnEmpty=True, retries=1)
59+
clients = {
60+
"tcp": ModbusTcpClient,
61+
"tls": ModbusTlsClient,
62+
"udp": ModbusUdpClient,
63+
"sungrow": SungrowModbusTcpClient.SungrowModbusTcpClient,
64+
# if 'serial' modbus is required at some point, the configuration
65+
# needs to be changed to provide file, baudrate etc.
66+
# "serial": (ModbusSerialClient, ModbusRtuFramer),
67+
}
68+
framers = {
69+
"ascii": ModbusAsciiFramer,
70+
"binary": ModbusBinaryFramer,
71+
"rtu": ModbusRtuFramer,
72+
"socket": ModbusSocketFramer,
73+
}
74+
75+
if self._variant is None:
76+
desired_framer, desired_client = None, 'tcp'
77+
elif "-over-" in self._variant:
78+
desired_framer, desired_client = self._variant.split('-over-')
6579
else:
66-
self._mb = ModbusTcpClient(self._ip, self._port,
67-
framer=ModbusSocketFramer, timeout=1,
68-
RetryOnEmpty=True, retries=1)
80+
desired_framer, desired_client = None, self._variant
81+
82+
if desired_client not in clients:
83+
raise ValueError("Unknown modbus client: {}".format(desired_client))
84+
if desired_framer is not None and desired_framer not in framers:
85+
raise ValueError("Unknown modbus framer: {}".format(desired_framer))
86+
87+
client = clients[desired_client]
88+
if desired_framer is None:
89+
framer = ModbusSocketFramer
90+
else:
91+
framer = framers[desired_framer]
92+
93+
self._mb = client(self._ip, self._port, RetryOnEmpty=True, framer=framer, retries=1, timeout=1)
6994

7095
def add_monitor_register(self, table, addr, type='uint16'):
7196
# Accepts a modbus register and table to monitor

Diff for: tests/test_modbus.py

+24
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,30 @@ def connect_failure(self):
4646
def throw_exception(self, addr, value, unit):
4747
raise ValueError('Oh noooo!')
4848

49+
def perform_variant_test(self, mock_modbus, variant, expected_framer):
50+
mock_modbus().connect.side_effect = self.connect_success
51+
mock_modbus().read_input_registers.side_effect = self.read_input_registers
52+
mock_modbus().read_holding_registers.side_effect = self.read_holding_registers
53+
54+
m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, variant)
55+
m.connect()
56+
mock_modbus.assert_called_with('1.1.1.1', 111, RetryOnEmpty=True, framer=expected_framer, retries=1, timeout=1)
57+
58+
def test_connection_variants(self):
59+
with patch('modbus4mqtt.modbus_interface.ModbusTcpClient') as mock_modbus:
60+
self.perform_variant_test(mock_modbus, None, modbus_interface.ModbusSocketFramer)
61+
self.perform_variant_test(mock_modbus, 'tcp', modbus_interface.ModbusSocketFramer)
62+
self.perform_variant_test(mock_modbus, 'rtu-over-tcp', modbus_interface.ModbusRtuFramer)
63+
with patch('modbus4mqtt.modbus_interface.ModbusUdpClient') as mock_modbus:
64+
self.perform_variant_test(mock_modbus, 'udp', modbus_interface.ModbusSocketFramer)
65+
self.perform_variant_test(mock_modbus, 'binary-over-udp', modbus_interface.ModbusBinaryFramer)
66+
67+
m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, 'notexisiting')
68+
self.assertRaises(ValueError, m.connect)
69+
70+
m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, 'notexisiting-over-tcp')
71+
self.assertRaises(ValueError, m.connect)
72+
4973
def test_connect(self):
5074
with patch('modbus4mqtt.modbus_interface.ModbusTcpClient') as mock_modbus:
5175
mock_modbus().connect.side_effect = self.connect_success

0 commit comments

Comments
 (0)