From e3c17c8f8c098a3f89c727b88617c5e7cb99c9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20L=C3=B6w?= Date: Tue, 16 Jan 2024 03:31:06 +0100 Subject: [PATCH] Allow configuration of modbus device address ("unit") and used write command (#50) * :sparkles: allow configuration of modbus device address and used write command * :white_check_mark: adjust tests to work with new modbus_interface arguments * :rotating_light: fix some flake8 warnings for modbus_interface.py * :white_check_mark: add unit tests for multi WriteMode * :memo: add device_address and write_mode options to the readme --- README.md | 2 ++ modbus4mqtt/modbus4mqtt.py | 13 +++++++-- modbus4mqtt/modbus_interface.py | 50 +++++++++++++++++++++++++-------- tests/test_modbus.py | 21 ++++++++++---- tests/test_mqtt.py | 18 ++++++++++-- 5 files changed, 81 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 178683c..2b1b242 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/modbus4mqtt/modbus4mqtt.py b/modbus4mqtt/modbus4mqtt.py index 33b9eda..656b45a 100755 --- a/modbus4mqtt/modbus4mqtt.py +++ b/modbus4mqtt/modbus4mqtt.py @@ -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) diff --git a/modbus4mqtt/modbus_interface.py b/modbus4mqtt/modbus_interface.py index c74dd0c..c301c9b 100644 --- a/modbus4mqtt/modbus_interface.py +++ b/modbus4mqtt/modbus_interface.py @@ -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. @@ -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 @@ -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 @@ -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, @@ -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 @@ -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 @@ -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. @@ -217,7 +242,8 @@ 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 @@ -225,16 +251,18 @@ def type_signed(type): 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) diff --git a/tests/test_modbus.py b/tests/test_modbus.py index f9a0c1e..98cf568 100644 --- a/tests/test_modbus.py +++ b/tests/test_modbus.py @@ -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 @@ -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) @@ -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): @@ -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) @@ -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) @@ -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 diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index e19d4e1..2fa5dc1 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -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: @@ -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: @@ -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: @@ -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__":