Skip to content

Commit

Permalink
Allow configuration of modbus device address ("unit") and used write …
Browse files Browse the repository at this point in the history
…command (#50)

* ✨ allow configuration of modbus device address and used write command

* ✅ adjust tests to work with new modbus_interface arguments

* 🚨 fix some flake8 warnings for modbus_interface.py

* ✅ add unit tests for multi WriteMode

* 📝 add device_address and write_mode options to the readme
  • Loading branch information
M4GNV5 authored Jan 16, 2024
1 parent c02482f commit e3c17c8
Show file tree
Hide file tree
Showing 5 changed files with 81 additions and 23 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ word_order: highlow
| ---------- | -------- | ------- | ----------- |
| ip | Required | N/A | The IP address of the modbus device to be polled. Presently only modbus TCP/IP is supported. |
| port | Optional | 502 | The port on the modbus device to connect to. |
| device_address | Optional | 1 | The modbus device address ("unit") of the target device |
| 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 | 'tcp' | Allows modbus variants to be specified. See below list for supported variants. |
| write_mode | Optional | 'single' | Which modbus write function code to use `single` for `06` or `multi` for `16` |
| 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. |

Expand Down
13 changes: 10 additions & 3 deletions modbus4mqtt/modbus4mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,16 @@ def connect_modbus(self):
else:
word_order = modbus_interface.WordOrder.HighLow

self._mb = modbus_interface.modbus_interface(self.config['ip'],
self.config.get('port', 502),
self.config.get('update_rate', 5),
if self.config.get('write_mode', 'single').lower() == 'multi':
write_mode = modbus_interface.WriteMode.Multi
else:
write_mode = modbus_interface.WriteMode.Single

self._mb = modbus_interface.modbus_interface(ip=self.config['ip'],
port=self.config.get('port', 502),
update_rate_s=self.config.get('update_rate', 5),
device_address=self.config.get('device_address', 0x01),
write_mode=write_mode,
variant=self.config.get('variant', None),
scan_batching=self.config.get('scan_batching', None),
word_order=word_order)
Expand Down
50 changes: 39 additions & 11 deletions modbus4mqtt/modbus_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,29 @@
DEFAULT_WRITE_SLEEP_S = 0.05
DEFAULT_READ_SLEEP_S = 0.05


class WordOrder(Enum):
HighLow = 1
LowHigh = 2


class WriteMode(Enum):
Single = 1
Multi = 2


class modbus_interface():

def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None, scan_batching=None, word_order=WordOrder.HighLow):
def __init__(self,
ip,
port=502,
update_rate_s=DEFAULT_SCAN_RATE_S,
device_address=0x01,
write_mode=WriteMode.Single,
variant=None,
scan_batching=None,
word_order=WordOrder.HighLow
):
self._ip = ip
self._port = port
# This is a dict of sets. Each key represents one table of modbus registers.
Expand All @@ -41,6 +57,8 @@ def __init__(self, ip, port=502, update_rate_s=DEFAULT_SCAN_RATE_S, variant=None

self._planned_writes = Queue()
self._writing = False
self._write_mode = write_mode
self._unit = device_address
self._variant = variant
self._scan_batching = DEFAULT_SCAN_BATCHING
self._word_order = word_order
Expand Down Expand Up @@ -135,7 +153,7 @@ def get_value(self, table, addr, type='uint16'):
data = self._values[table][addr + i]
else:
data = self._values[table][addr + (type_len-i-1)]
value += data.to_bytes(2,'big')
value += data.to_bytes(2, 'big')
value = _convert_from_bytes_to_type(value, type)
return value

Expand All @@ -158,6 +176,12 @@ def set_value(self, table, addr, value, mask=0xFFFF, type='uint16'):

self._process_writes()

def _perform_write(self, addr, value):
if self._write_mode == WriteMode.Single:
self._mb.write_register(addr, value, unit=self._unit)
else:
self._mb.write_registers(addr, [value], unit=self._unit)

def _process_writes(self, max_block_s=DEFAULT_WRITE_BLOCK_INTERVAL_S):
# TODO I am not entirely happy with this system. It's supposed to prevent
# anything overwhelming the modbus interface with a heap of rapid writes,
Expand All @@ -171,7 +195,7 @@ def _process_writes(self, max_block_s=DEFAULT_WRITE_BLOCK_INTERVAL_S):
while not self._planned_writes.empty() and (time() - write_start_time) < max_block_s:
addr, value, mask = self._planned_writes.get()
if mask == 0xFFFF:
self._mb.write_register(addr, value, unit=0x01)
self._perform_write(addr, value)
else:
# https://pymodbus.readthedocs.io/en/latest/source/library/pymodbus.client.html?highlight=mask_write_register#pymodbus.client.common.ModbusClientMixin.mask_write_register
# https://www.mathworks.com/help/instrument/modify-the-contents-of-a-holding-register-using-a-mask-write.html
Expand All @@ -184,10 +208,10 @@ def _process_writes(self, max_block_s=DEFAULT_WRITE_BLOCK_INTERVAL_S):
# result = self._mb.mask_write_register(address=addr, and_mask=(1<<16)-1-mask, or_mask=value, unit=0x01)
# print("Result: {}".format(result))
old_value = self._scan_value_range('holding', addr, 1)[0]
and_mask = (1<<16)-1-mask
and_mask = (1 << 16) - 1 - mask
or_mask = value
new_value = (old_value & and_mask) | (or_mask & (mask))
self._mb.write_register(addr, new_value, unit=0x01)
self._perform_write(addr, new_value)
sleep(DEFAULT_WRITE_SLEEP_S)
except Exception as e:
# BUG catch only the specific exception that means pymodbus failed to write to a register
Expand All @@ -199,15 +223,16 @@ def _process_writes(self, max_block_s=DEFAULT_WRITE_BLOCK_INTERVAL_S):
def _scan_value_range(self, table, start, count):
result = None
if table == 'input':
result = self._mb.read_input_registers(start, count, unit=0x01)
result = self._mb.read_input_registers(start, count, unit=self._unit)
elif table == 'holding':
result = self._mb.read_holding_registers(start, count, unit=0x01)
result = self._mb.read_holding_registers(start, count, unit=self._unit)
try:
return result.registers
except:
# The result doesn't have a registers attribute, something has gone wrong!
raise ValueError("Failed to read {} {} table registers starting from {}: {}".format(count, table, start, result))


def type_length(type):
# Return the number of addresses needed for the type.
# Note: Each address provides 2 bytes of data.
Expand All @@ -217,24 +242,27 @@ def type_length(type):
return 2
elif type in ['int64', 'uint64']:
return 4
raise ValueError ("Unsupported type {}".format(type))
raise ValueError("Unsupported type {}".format(type))


def type_signed(type):
# Returns whether the provided type is signed
if type in ['uint16', 'uint32', 'uint64']:
return False
elif type in ['int16', 'int32', 'int64']:
return True
raise ValueError ("Unsupported type {}".format(type))
raise ValueError("Unsupported type {}".format(type))


def _convert_from_bytes_to_type(value, type):
type = type.strip().lower()
signed = type_signed(type)
return int.from_bytes(value,byteorder='big',signed=signed)
return int.from_bytes(value, byteorder='big', signed=signed)


def _convert_from_type_to_bytes(value, type):
type = type.strip().lower()
signed = type_signed(type)
# This can throw an OverflowError in various conditons. This will usually
# percolate upwards and spit out an exception from on_message.
return int(value).to_bytes(type_length(type)*2,byteorder='big',signed=signed)
return int(value).to_bytes(type_length(type) * 2, byteorder='big', signed=signed)
21 changes: 15 additions & 6 deletions tests/test_modbus.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def read_holding_registers(self, start, count, unit):
def write_holding_register(self, address, value, unit):
self.holding_registers.registers[address] = value

def write_holding_registers(self, address, values, unit):
self.assertEquals(len(values), 1)
self.holding_registers.registers[address] = values[0]

def connect_success(self):
return False

Expand All @@ -51,7 +55,7 @@ def perform_variant_test(self, mock_modbus, variant, expected_framer):
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 = modbus_interface.modbus_interface(ip='1.1.1.1', port=111, variant=variant)
m.connect()
mock_modbus.assert_called_with('1.1.1.1', 111, RetryOnEmpty=True, framer=expected_framer, retries=1, timeout=1)

Expand All @@ -64,10 +68,10 @@ def test_connection_variants(self):
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')
m = modbus_interface.modbus_interface(ip='1.1.1.1', port=111, variant='notexisiting')
self.assertRaises(ValueError, m.connect)

m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, 'notexisiting-over-tcp')
m = modbus_interface.modbus_interface(ip='1.1.1.1', port=111, variant='notexisiting-over-tcp')
self.assertRaises(ValueError, m.connect)

def test_connect(self):
Expand All @@ -76,7 +80,7 @@ def test_connect(self):
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)
m = modbus_interface.modbus_interface(ip='1.1.1.1', port=111)
m.connect()
mock_modbus.assert_called_with('1.1.1.1', 111, RetryOnEmpty=True, framer=modbus_interface.ModbusSocketFramer, retries=1, timeout=1)

Expand Down Expand Up @@ -341,13 +345,14 @@ def test_multi_byte_write_counts_LowHigh_order(self):
mock_modbus().write_register.assert_any_call(4, int.from_bytes(b'\x4B\xD6','big'), unit=1)
mock_modbus().reset_mock()

def test_multi_byte_read_write_values(self):
def perform_multi_byte_read_write_values_test(self, write_mode):
with patch('modbus4mqtt.modbus_interface.ModbusTcpClient') as mock_modbus:
mock_modbus().connect.side_effect = self.connect_success
mock_modbus().read_holding_registers.side_effect = self.read_holding_registers
mock_modbus().write_register.side_effect = self.write_holding_register
mock_modbus().write_registers.side_effect = self.write_holding_registers

m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, scan_batching=1)
m = modbus_interface.modbus_interface('1.1.1.1', 111, 2, scan_batching=1, write_mode=write_mode)
m.connect()
mock_modbus.assert_called_with('1.1.1.1', 111, RetryOnEmpty=True, framer=modbus_interface.ModbusSocketFramer, retries=1, timeout=1)

Expand Down Expand Up @@ -378,6 +383,10 @@ def test_multi_byte_read_write_values(self):
# Read the value out as a different type.
self.assertEqual(m.get_value('holding', 1, 'int64'), -170869853354175)

def test_multi_byte_read_write_values(self):
self.perform_multi_byte_read_write_values_test(modbus_interface.WriteMode.Single)
self.perform_multi_byte_read_write_values_test(modbus_interface.WriteMode.Multi)

def test_multi_byte_read_write_values_LowHigh(self):
with patch('modbus4mqtt.modbus_interface.ModbusTcpClient') as mock_modbus:
mock_modbus().connect.side_effect = self.connect_success
Expand Down
18 changes: 15 additions & 3 deletions tests/test_mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,18 @@ def test_register_validation(self):
if not fail:
self.fail("Didn't throw an exception checking an invalid register configuration")

def assert_modbus_call(self, mock_modbus, word_order=modbus4mqtt.modbus_interface.WordOrder.HighLow):
mock_modbus.assert_any_call(
ip='192.168.1.90',
port=502,
update_rate_s=5,
device_address=1,
write_mode=modbus4mqtt.modbus_interface.WriteMode.Single,
variant=None,
scan_batching=None,
word_order=word_order
)

def test_word_order_setting(self):
with patch('paho.mqtt.client.Client') as mock_mqtt:
with patch('modbus4mqtt.modbus_interface.modbus_interface') as mock_modbus:
Expand All @@ -500,7 +512,7 @@ def test_word_order_setting(self):
# Default value
m = modbus4mqtt.mqtt_interface('kroopit', 1885, 'brengis', 'pranto', './tests/test_type.yaml', MQTT_TOPIC_PREFIX)
m.connect()
mock_modbus.assert_any_call('192.168.1.90', 502, 5, scan_batching=None, variant=None, word_order=modbus4mqtt.modbus_interface.WordOrder.HighLow)
self.assert_modbus_call(mock_modbus)

with patch('paho.mqtt.client.Client') as mock_mqtt:
with patch('modbus4mqtt.modbus_interface.modbus_interface') as mock_modbus:
Expand All @@ -511,7 +523,7 @@ def test_word_order_setting(self):
# Explicit HighLow
m = modbus4mqtt.mqtt_interface('kroopit', 1885, 'brengis', 'pranto', './tests/test_word_order.yaml', MQTT_TOPIC_PREFIX)
m.connect()
mock_modbus.assert_any_call('192.168.1.90', 502, 5, scan_batching=None, variant=None, word_order=modbus4mqtt.modbus_interface.WordOrder.HighLow)
self.assert_modbus_call(mock_modbus)

with patch('paho.mqtt.client.Client') as mock_mqtt:
with patch('modbus4mqtt.modbus_interface.modbus_interface') as mock_modbus:
Expand All @@ -522,7 +534,7 @@ def test_word_order_setting(self):
# Explicit HighLow
m = modbus4mqtt.mqtt_interface('kroopit', 1885, 'brengis', 'pranto', './tests/test_word_order_low_high.yaml', MQTT_TOPIC_PREFIX)
m.connect()
mock_modbus.assert_any_call('192.168.1.90', 502, 5, scan_batching=None, variant=None, word_order=modbus4mqtt.modbus_interface.WordOrder.LowHigh)
self.assert_modbus_call(mock_modbus, modbus4mqtt.modbus_interface.WordOrder.LowHigh)


if __name__ == "__main__":
Expand Down

0 comments on commit e3c17c8

Please sign in to comment.