Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for more modbus connection variants #47

Merged
merged 2 commits into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 splitted into two: The connection variant and the framer variant using the format `<framer>-over-<connection>` or just `<connection>`.
M4GNV5 marked this conversation as resolved.
Show resolved Hide resolved
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

Check warning on line 11 in modbus4mqtt/modbus_interface.py

View check run for this annotation

Codecov / codecov/patch

modbus4mqtt/modbus_interface.py#L11

Added line #L11 was not covered by tests
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 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
Loading