From 8bb12f5a5ade311ef5cfa251cc8544aba078711c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 27 Aug 2024 12:50:55 +0200 Subject: [PATCH 01/41] Prepare dev. --- pymodbus/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymodbus/__init__.py b/pymodbus/__init__.py index a69ccf850..eaad4542d 100644 --- a/pymodbus/__init__.py +++ b/pymodbus/__init__.py @@ -18,5 +18,5 @@ from pymodbus.pdu import ExceptionResponse -__version__ = "3.7.2" +__version__ = "3.7.3dev" __version_full__ = f"[pymodbus, version {__version__}]" From 03542f6b084370101f8a7aa5508822ace75bda1f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 2 Sep 2024 15:35:31 +0200 Subject: [PATCH 02/41] Remove _header from framers. (#2305) --- examples/message_parser.py | 5 +- pymodbus/framer/old_framer_ascii.py | 4 +- pymodbus/framer/old_framer_base.py | 34 ++++-------- pymodbus/framer/old_framer_rtu.py | 30 +++++------ pymodbus/framer/old_framer_socket.py | 10 ++-- pymodbus/framer/old_framer_tls.py | 6 +-- test/framers/test_framer.py | 26 +++++----- test/framers/test_old_framers.py | 78 ++++++---------------------- test/framers/test_socket.py | 8 +-- test/framers/test_tbc_transaction.py | 5 +- test/test_transaction.py | 5 +- 11 files changed, 66 insertions(+), 145 deletions(-) diff --git a/examples/message_parser.py b/examples/message_parser.py index 67bf68191..10c807f81 100755 --- a/examples/message_parser.py +++ b/examples/message_parser.py @@ -80,10 +80,7 @@ def decode(self, message): print(f"{decoder.decoder.__class__.__name__}") print("-" * 80) try: - slave = decoder._header.get( # pylint: disable=protected-access - "uid", 0x00 - ) - decoder.processIncomingPacket(message, self.report, slave) + decoder.processIncomingPacket(message, self.report, 0) except Exception: # pylint: disable=broad-except self.check_errors(decoder, message) diff --git a/pymodbus/framer/old_framer_ascii.py b/pymodbus/framer/old_framer_ascii.py index 1773e4592..780d1cf9b 100644 --- a/pymodbus/framer/old_framer_ascii.py +++ b/pymodbus/framer/old_framer_ascii.py @@ -57,7 +57,7 @@ def frameProcessIncomingPacket(self, single, callback, slave, tid=None): return self._buffer = self._buffer[used_len :] continue - self._header["uid"] = dev_id + self.dev_id = dev_id if not self._validate_slave_id(slave, single): Log.error("Not a valid slave id - {}, ignoring!!", dev_id) self.resetFrame() @@ -67,5 +67,5 @@ def frameProcessIncomingPacket(self, single, callback, slave, tid=None): raise ModbusIOException("Unable to decode response") self.populateResult(result) self._buffer = self._buffer[used_len :] - self._header = {"uid": 0x00} + self.dev_id = 0 callback(result) # defer this diff --git a/pymodbus/framer/old_framer_base.py b/pymodbus/framer/old_framer_base.py index b737a1b47..20eec5c14 100644 --- a/pymodbus/framer/old_framer_base.py +++ b/pymodbus/framer/old_framer_base.py @@ -3,7 +3,7 @@ from __future__ import annotations import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from pymodbus.factory import ClientDecoder, ServerDecoder from pymodbus.framer.base import FramerBase @@ -41,20 +41,10 @@ def __init__( """ self.decoder = decoder self.client = client - self._header: dict[str, Any] - self._reset_header() self._buffer = b"" self.message_handler: FramerBase - - def _reset_header(self) -> None: - self._header = { - "lrc": "0000", - "len": 0, - "uid": 0x00, - "tid": 0, - "pid": 0, - "crc": b"\x00\x00", - } + self.tid = 0 + self.dev_id = 0 def _validate_slave_id(self, slaves: list, single: bool) -> bool: """Validate if the received data is valid for the client. @@ -69,7 +59,7 @@ def _validate_slave_id(self, slaves: list, single: bool) -> bool: # Handle Modbus TCP slave identifier (0x00 0r 0xFF) # in asynchronous requests return True - return self._header["uid"] in slaves + return self.dev_id in slaves def sendPacket(self, message: bytes): """Send packets on the bus. @@ -104,14 +94,8 @@ def resetFrame(self): "Resetting frame - Current Frame in buffer - {}", self._buffer, ":hex" ) self._buffer = b"" - self._header = { - "lrc": "0000", - "crc": b"\x00\x00", - "len": 0, - "uid": 0x00, - "pid": 0, - "tid": 0, - } + self.dev_id = 0 + self.tid = 0 def populateResult(self, result): """Populate the modbus result header. @@ -121,9 +105,9 @@ def populateResult(self, result): :param result: The response packet """ - result.slave_id = self._header.get("uid", 0) - result.transaction_id = self._header.get("tid", 0) - result.protocol_id = self._header.get("pid", 0) + result.slave_id = self.dev_id + result.transaction_id = self.tid + result.protocol_id = 0 def processIncomingPacket(self, data: bytes, callback, slave, single=False, tid=None): """Process new packet pattern. diff --git a/pymodbus/framer/old_framer_rtu.py b/pymodbus/framer/old_framer_rtu.py index 88478b0bd..dcd311098 100644 --- a/pymodbus/framer/old_framer_rtu.py +++ b/pymodbus/framer/old_framer_rtu.py @@ -60,6 +60,7 @@ def __init__(self, decoder, client=None): self._hsize = 0x01 self.function_codes = decoder.lookup.keys() if decoder else {} self.message_handler = FramerRTU() + self.msg_len = 0 def decode_data(self, data): """Decode data.""" @@ -75,20 +76,17 @@ def frameProcessIncomingPacket(self, _single, callback, slave, tid=None): # noq def is_frame_ready(self): """Check if we should continue decode logic.""" - size = self._header.get("len", 0) + size = self.msg_len if not size and len(self._buffer) > self._hsize: try: - self._header["uid"] = int(self._buffer[0]) - self._header["tid"] = int(self._buffer[0]) - self._header["tid"] = 0 # fix for now + self.dev_id = int(self._buffer[0]) func_code = int(self._buffer[1]) pdu_class = self.decoder.lookupPduClass(func_code) size = pdu_class.calculateRtuFrameSize(self._buffer) - self._header["len"] = size + self.msg_len = size if len(self._buffer) < size: raise IndexError - self._header["crc"] = self._buffer[size - 2 : size] except IndexError: return False return len(self._buffer) >= size if size > 0 else False @@ -116,20 +114,17 @@ def get_frame_start(self, slaves, broadcast, skip_cur_frame): def check_frame(self): """Check if the next frame is available.""" try: - self._header["uid"] = int(self._buffer[0]) - self._header["tid"] = int(self._buffer[0]) - self._header["tid"] = 0 # fix for now + self.dev_id = int(self._buffer[0]) func_code = int(self._buffer[1]) pdu_class = self.decoder.lookupPduClass(func_code) size = pdu_class.calculateRtuFrameSize(self._buffer) - self._header["len"] = size + self.msg_len = size if len(self._buffer) < size: raise IndexError - self._header["crc"] = self._buffer[size - 2 : size] - frame_size = self._header["len"] + frame_size = self.msg_len data = self._buffer[: frame_size - 2] - crc = self._header["crc"] + crc = self._buffer[size - 2 : size] crc_val = (int(crc[0]) << 8) + int(crc[1]) return FramerRTU.check_CRC(data, crc_val) except (IndexError, KeyError, struct.error): @@ -138,7 +133,8 @@ def check_frame(self): broadcast = not slave[0] skip_cur_frame = False while get_frame_start(self, slave, broadcast, skip_cur_frame): - self._header = {"uid": 0x00, "len": 0, "crc": b"\x00\x00"} + self.dev_id = 0 + self.msg_len = 0 if not is_frame_ready(self): Log.debug("Frame - not ready") break @@ -150,7 +146,7 @@ def check_frame(self): skip_cur_frame = True continue start = self._hsize - end = self._header["len"] - 2 + end = self.msg_len - 2 buffer = self._buffer[start:end] if end > 0: Log.debug("Getting Frame - {}", buffer, ":hex") @@ -159,9 +155,9 @@ def check_frame(self): data = b"" if (result := self.decoder.decode(data)) is None: raise ModbusIOException("Unable to decode request") - result.slave_id = self._header["uid"] + result.slave_id = self.dev_id result.transaction_id = 0 - self._buffer = self._buffer[self._header["len"] :] + self._buffer = self._buffer[self.msg_len :] Log.debug("Frame advanced, resetting header!!") callback(result) # defer or push to a thread? diff --git a/pymodbus/framer/old_framer_socket.py b/pymodbus/framer/old_framer_socket.py index 65bc9ba4e..88668566f 100644 --- a/pymodbus/framer/old_framer_socket.py +++ b/pymodbus/framer/old_framer_socket.py @@ -47,12 +47,10 @@ def __init__(self, decoder, client=None): def decode_data(self, data): """Decode data.""" if len(data) > self._hsize: - tid, pid, length, uid, fcode = struct.unpack( + _tid, _pid, length, uid, fcode = struct.unpack( SOCKET_FRAME_HEADER, data[0 : self._hsize + 1] ) return { - "tid": tid, - "pid": pid, "length": length, "slave": uid, "fcode": fcode, @@ -77,9 +75,8 @@ def frameProcessIncomingPacket(self, single, callback, slave, tid=None): used_len, use_tid, dev_id, data = self.message_handler.decode(self._buffer) if not data: return - self._header["uid"] = dev_id - self._header["tid"] = use_tid - self._header["pid"] = 0 + self.dev_id = dev_id + self.tid = use_tid if not self._validate_slave_id(slave, single): Log.debug("Not a valid slave id - {}, ignoring!!", dev_id) self.resetFrame() @@ -89,7 +86,6 @@ def frameProcessIncomingPacket(self, single, callback, slave, tid=None): raise ModbusIOException("Unable to decode request") self.populateResult(result) self._buffer: bytes = self._buffer[used_len:] - self._reset_header() if tid and tid != result.transaction_id: self.resetFrame() else: diff --git a/pymodbus/framer/old_framer_tls.py b/pymodbus/framer/old_framer_tls.py index c34470f4e..ae42d1f99 100644 --- a/pymodbus/framer/old_framer_tls.py +++ b/pymodbus/framer/old_framer_tls.py @@ -56,14 +56,12 @@ def frameProcessIncomingPacket(self, _single, callback, _slave, tid=None): used_len, use_tid, dev_id, data = self.message_handler.decode(self._buffer) if not data: return - self._header["uid"] = dev_id - self._header["tid"] = use_tid - self._header["pid"] = 0 + self.dev_id = dev_id + self.tid = use_tid if (result := self.decoder.decode(data)) is None: self.resetFrame() raise ModbusIOException("Unable to decode request") self.populateResult(result) self._buffer: bytes = self._buffer[used_len:] - self._reset_header() callback(result) # defer or push to a thread? diff --git a/test/framers/test_framer.py b/test/framers/test_framer.py index 59e6cdda1..725197fbc 100644 --- a/test/framers/test_framer.py +++ b/test/framers/test_framer.py @@ -74,17 +74,17 @@ async def test_framer_decode(self, dummy_framer, data, res_id, res_tid, res_len assert res_data == t_data @pytest.mark.parametrize( - ("data", "dev_id", "tid", "res_data"), [ + ("data", "dev_id", "tr_id", "res_data"), [ (b'\x01\x02', 5, 6, b'\x05\x06\x01\x02'), (b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09', 17, 25, b'\x11\x19\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09'), ]) - async def test_framer_encode(self, dummy_framer, data, dev_id, tid, res_data): + async def test_framer_encode(self, dummy_framer, data, dev_id, tr_id, res_data): """Test decode method in all types.""" - t_data = dummy_framer.handle.encode(data, dev_id, tid) + t_data = dummy_framer.handle.encode(data, dev_id, tr_id) assert res_data == t_data @pytest.mark.parametrize( - ("func", "lrc", "expect"), + ("func", "test_compare", "expect"), [(FramerAscii.check_LRC, 0x1c, True), (FramerAscii.check_LRC, 0x0c, False), (FramerAscii.compute_LRC, None, 0x1c), @@ -93,10 +93,10 @@ async def test_framer_encode(self, dummy_framer, data, dev_id, tid, res_data): (FramerRTU.compute_CRC, None, 0xE2DB), ] ) - def test_LRC_CRC(self, func, lrc, expect): + def test_LRC_CRC(self, func, test_compare, expect): """Test check_LRC.""" data = b'\x12\x34\x23\x45\x34\x56\x45\x67' - assert expect == func(data, lrc) if lrc else func(data) + assert expect == func(data, test_compare) if test_compare else func(data) def test_roundtrip_LRC(self): """Test combined compute/check LRC.""" @@ -226,23 +226,23 @@ class TestFramerType: ] ) @pytest.mark.parametrize( - ("inx3", "tid"), + ("inx3", "tr_id"), [ (0, 0), (9, 3077), ] ) - def test_encode_type(self, frame, frame_expected, data, dev_id, tid, inx1, inx2, inx3): + def test_encode_type(self, frame, frame_expected, data, dev_id, tr_id, inx1, inx2, inx3): """Test encode method.""" - if frame == FramerTLS and dev_id + tid: + if frame == FramerTLS and dev_id + tr_id: return frame_obj = frame() expected = frame_expected[inx1 + inx2 + inx3] - encoded_data = frame_obj.encode(data, dev_id, tid) + encoded_data = frame_obj.encode(data, dev_id, tr_id) assert encoded_data == expected @pytest.mark.parametrize( - ("entry", "is_server", "data", "dev_id", "tid", "expected"), + ("entry", "is_server", "data", "dev_id", "tr_id", "expected"), [ (FramerType.ASCII, True, b':0003007C00027F\r\n', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request (FramerType.ASCII, False, b':000304008D008EDE\r\n', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response @@ -293,7 +293,7 @@ def test_encode_type(self, frame, frame_expected, data, dev_id, tid, inx1, inx2, "single", ] ) - async def test_decode_type(self, entry, dummy_framer, data, dev_id, tid, expected, split): + async def test_decode_type(self, entry, dummy_framer, data, dev_id, tr_id, expected, split): """Test encode method.""" if entry == FramerType.TLS and split != "no": return @@ -314,7 +314,7 @@ async def test_decode_type(self, entry, dummy_framer, data, dev_id, tid, expecte dummy_framer.callback_request_response.assert_not_called() used_len = dummy_framer.callback_data(data) assert used_len == len(data) - dummy_framer.callback_request_response.assert_called_with(expected, dev_id, tid) + dummy_framer.callback_request_response.assert_called_with(expected, dev_id, tr_id) @pytest.mark.parametrize( ("entry", "data", "exp"), diff --git a/test/framers/test_old_framers.py b/test/framers/test_old_framers.py index 56758bcb3..91adb6864 100644 --- a/test/framers/test_old_framers.py +++ b/test/framers/test_old_framers.py @@ -58,36 +58,15 @@ def test_framer_initialization(self, framer): assert framer._buffer == b"" # pylint: disable=protected-access assert framer.decoder == decoder if isinstance(framer, ModbusAsciiFramer): - assert framer._header == { # pylint: disable=protected-access - "tid": 0, - "pid": 0, - "lrc": "0000", - "len": 0, - "uid": 0x00, - "crc": b"\x00\x00", - } + assert not framer.dev_id assert framer._hsize == 0x02 # pylint: disable=protected-access assert framer._start == b":" # pylint: disable=protected-access assert framer._end == b"\r\n" # pylint: disable=protected-access elif isinstance(framer, ModbusRtuFramer): - assert framer._header == { # pylint: disable=protected-access - "tid": 0, - "pid": 0, - "lrc": "0000", - "uid": 0x00, - "len": 0, - "crc": b"\x00\x00", - } + assert not framer.dev_id assert framer._hsize == 0x01 # pylint: disable=protected-access else: - assert framer._header == { # pylint: disable=protected-access - "tid": 0, - "pid": 0, - "lrc": "0000", - "crc": b"\x00\x00", - "len": 0, - "uid": 0x00, - } + assert not framer.dev_id assert framer._hsize == 0x07 # pylint: disable=protected-access @@ -115,18 +94,14 @@ def callback(data): @pytest.mark.parametrize( - ("data", "header", "res"), + ("data", "res"), [ - (b"", {"uid": 0x00, "len": 0, "crc": b"\x00\x00"}, 0), - (b"abcd", {"uid": 0x00, "len": 2, "crc": b"\x00\x00"}, 0), - ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x12\x03", # real case, frame size is 11 - {"uid": 0x00, "len": 11, "crc": b"\x00\x00"}, - 1, - ), + (b"", 0), + (b"abcd", 0), + (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x12\x03", 1), # real case, frame size is 11 ], ) - def test_rtu_advance_framer(self, rtu_framer, data, header, res): + def test_rtu_advance_framer(self, rtu_framer, data, res): """Test rtu advance framer.""" count = 0 result = None @@ -136,7 +111,7 @@ def callback(data): count += 1 result = data - rtu_framer._header = header # pylint: disable=protected-access + rtu_framer.dev_id = 0 rtu_framer.processIncomingPacket(data, callback, self.slaves) assert count == res @@ -146,14 +121,7 @@ def test_rtu_reset_framer(self, rtu_framer, data): """Test rtu reset framer.""" rtu_framer._buffer = data # pylint: disable=protected-access rtu_framer.resetFrame() - assert rtu_framer._header == { # pylint: disable=protected-access - "lrc": "0000", - "crc": b"\x00\x00", - "len": 0, - "uid": 0x00, - "pid": 0, - "tid": 0, - } + assert not rtu_framer.dev_id @pytest.mark.parametrize( @@ -192,7 +160,7 @@ def callback(data): b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x43", ], ) - def test_rtu_populate_header_fail(self, rtu_framer, data): + def test_rtu_populate_fail(self, rtu_framer, data): """Test rtu populate header fail.""" count = 0 result = None @@ -208,29 +176,17 @@ def callback(data): assert count @pytest.mark.parametrize( - ("data", "header"), + ("data", "dev_id"), [ ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", - { - "crc": b"\x49\xAD", - "uid": 17, - "len": 11, - "tid": 0, - }, + b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", 17, ), ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x11\x03", - { - "crc": b"\x49\xAD", - "uid": 17, - "len": 11, - "tid": 0, - }, + b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x11\x03", 17, ), ], ) - def test_rtu_populate_header(self, rtu_framer, data, header): + def test_rtu_populate(self, rtu_framer, data, dev_id): """Test rtu populate header.""" count = 0 result = None @@ -241,7 +197,7 @@ def callback(data): result = data rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert rtu_framer._header == header # pylint: disable=protected-access + assert rtu_framer.dev_id == dev_id def test_get_frame(self, rtu_framer): @@ -262,7 +218,7 @@ def callback(data): def test_populate_result(self, rtu_framer): """Test populate result.""" - rtu_framer._header["uid"] = 255 # pylint: disable=protected-access + rtu_framer.dev_id = 255 result = mock.Mock() rtu_framer.populateResult(result) assert result.slave_id == 255 diff --git a/test/framers/test_socket.py b/test/framers/test_socket.py index d716d4c7d..6c1b496fb 100644 --- a/test/framers/test_socket.py +++ b/test/framers/test_socket.py @@ -35,7 +35,7 @@ def test_decode(self, frame, packet, used_len, res_id, res_tid, res): @pytest.mark.parametrize( - ("data", "dev_id", "tid", "res_msg"), + ("data", "dev_id", "tr_id", "res_msg"), [ (b'\x01\x05\x04\x00\x17', 7, 5, b'\x00\x05\x00\x00\x00\x06\x07\x01\x05\x04\x00\x17'), (b'\x03\x07\x06\x00\x73', 2, 9, b'\x00\x09\x00\x00\x00\x06\x02\x03\x07\x06\x00\x73'), @@ -43,11 +43,11 @@ def test_decode(self, frame, packet, used_len, res_id, res_tid, res): (b'\x84\x01', 4, 8, b'\x00\x08\x00\x00\x00\x03\x04\x84\x01'), ], ) - def test_roundtrip(self, frame, data, dev_id, tid, res_msg): + def test_roundtrip(self, frame, data, dev_id, tr_id, res_msg): """Test encode.""" - msg = frame.encode(data, dev_id, tid) + msg = frame.encode(data, dev_id, tr_id) res_len, res_tid, res_id, res_data = frame.decode(msg) assert data == res_data assert dev_id == res_id - assert tid == res_tid + assert tr_id == res_tid assert res_len == len(res_msg) diff --git a/test/framers/test_tbc_transaction.py b/test/framers/test_tbc_transaction.py index 32d64495d..411eb3784 100755 --- a/test/framers/test_tbc_transaction.py +++ b/test/framers/test_tbc_transaction.py @@ -554,10 +554,7 @@ def callback(data): msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" self._rtu.processIncomingPacket(msg, callback, [0, 1]) - header_dict = self._rtu._header # pylint: disable=protected-access - assert len(msg) == header_dict["len"] - assert int(msg[0]) == header_dict["uid"] - assert msg[-2:] == header_dict["crc"] + assert int(msg[0]) == self._rtu.dev_id def test_rtu_framer_packet(self): """Test a rtu frame packet build.""" diff --git a/test/test_transaction.py b/test/test_transaction.py index 81c829cb6..741002d80 100755 --- a/test/test_transaction.py +++ b/test/test_transaction.py @@ -540,10 +540,7 @@ def callback(data): msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" self._rtu.processIncomingPacket(msg, callback, [0, 1]) - header_dict = self._rtu._header # pylint: disable=protected-access - assert len(msg) == header_dict["len"] - assert int(msg[0]) == header_dict["uid"] - assert msg[-2:] == header_dict["crc"] + assert int(msg[0]) == self._rtu.dev_id @mock.patch.object(ModbusRequest, "encode") def test_rtu_framer_packet(self, mock_encode): From f9e05bc9d21748fe6df5a45f3321801dc70cb763 Mon Sep 17 00:00:00 2001 From: Daniel Rauber Date: Sat, 7 Sep 2024 10:06:23 +0200 Subject: [PATCH 03/41] fixed type hints for write_register and write_registers (#2309) --- pymodbus/client/mixin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pymodbus/client/mixin.py b/pymodbus/client/mixin.py index 45f563a0c..995d72110 100644 --- a/pymodbus/client/mixin.py +++ b/pymodbus/client/mixin.py @@ -111,7 +111,7 @@ def write_coil(self, address: int, value: bool, slave: int = 1) -> T: """ return self.execute(pdu_bit_write.WriteSingleCoilRequest(address, value, slave=slave)) - def write_register(self, address: int, value: int, slave: int = 1) -> T: + def write_register(self, address: int, value: bytes, slave: int = 1) -> T: """Write register (code 0x06). :param address: Address to write to @@ -322,7 +322,7 @@ def write_coils( ) def write_registers( - self, address: int, values: list[int], slave: int = 1, skip_encode: bool = False) -> T: + self, address: int, values: list[bytes], slave: int = 1, skip_encode: bool = False) -> T: """Write registers (code 0x10). :param address: Start address to write to From eb1d574841357dcc2784651ddff9e5be972902c5 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 9 Sep 2024 12:00:10 +0200 Subject: [PATCH 04/41] Complete pull request #2310 (#2312) * Update BinaryPayloadDecoder._unpack_words documentation Co-authored-by: Michel F <80367602+MrWaloo@users.noreply.github.com> --- pymodbus/payload.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pymodbus/payload.py b/pymodbus/payload.py index 3f20ad46d..b6ec8e1cb 100644 --- a/pymodbus/payload.py +++ b/pymodbus/payload.py @@ -330,9 +330,6 @@ def _unpack_words(self, handle) -> bytes: # Change Word order if little endian word order # # Pack values back based on correct byte order # # ---------------------------------------------- # - :param fstring: - :param handle: Value to be unpacked - :return: """ if Endian.LITTLE in {self._byteorder, self._wordorder}: handle = array("H", handle) From dbc64084877c34df24c572c4740b7ef861477826 Mon Sep 17 00:00:00 2001 From: Michel F <80367602+MrWaloo@users.noreply.github.com> Date: Wed, 11 Sep 2024 13:26:57 +0200 Subject: [PATCH 05/41] Remove double conversion in int (#2315) --- pymodbus/utilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymodbus/utilities.py b/pymodbus/utilities.py index d73c153bb..b0274116d 100644 --- a/pymodbus/utilities.py +++ b/pymodbus/utilities.py @@ -138,7 +138,7 @@ def unpack_bitstring(data: bytes) -> list[bool]: byte_count = len(data) bits = [] for byte in range(byte_count): - value = int(int(data[byte])) + value = int(data[byte]) for _ in range(8): bits.append((value & 1) == 1) value >>= 1 From c44b5a1ecb764fb66e6516d2307708aa27fa84d3 Mon Sep 17 00:00:00 2001 From: Michel F <80367602+MrWaloo@users.noreply.github.com> Date: Thu, 12 Sep 2024 21:26:55 +0200 Subject: [PATCH 06/41] Update solar.py (#2316) --- examples/contrib/solar.py | 115 ++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 42 deletions(-) diff --git a/examples/contrib/solar.py b/examples/contrib/solar.py index a2e951f96..a9f726993 100755 --- a/examples/contrib/solar.py +++ b/examples/contrib/solar.py @@ -3,8 +3,12 @@ Modified to test long term connection. +Modified to actually work with Huawei SUN2000 inverters, that better support async Modbus operations so errors will occur +Configure HOST (the IP address of the inverter as a string), PORT and CYCLES to fit your needs + """ import logging +from enum import Enum from time import sleep from pymodbus import pymodbus_apply_logging_config @@ -14,30 +18,37 @@ # --------------------------------------------------------------------------- # from pymodbus.client import ModbusTcpClient from pymodbus.exceptions import ModbusException -from pymodbus.transaction import ModbusSocketFramer +from pymodbus.pdu import ExceptionResponse +from pymodbus import FramerType + + +HOST = "modbusServer.lan" +PORT = 502 +CYCLES = 4 +pymodbus_apply_logging_config(logging.ERROR) +logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s %(levelname)s %(message)s') _logger = logging.getLogger(__file__) -_logger.setLevel(logging.DEBUG) -def main(): +def main() -> None: """Run client setup.""" - pymodbus_apply_logging_config(logging.DEBUG) _logger.info("### Client starting") - client = ModbusTcpClient( - "modbusServer.lan", - port=502, + client: ModbusTcpClient = ModbusTcpClient( + host=HOST, + port=PORT, # Common optional parameters: - framer=ModbusSocketFramer, + framer=FramerType.SOCKET, timeout=1, ) client.connect() _logger.info("### Client connected") - sleep(5) + sleep(1) _logger.info("### Client starting") sleep_time = 2 - for count in range(int(60 / sleep_time) * 60 * 3): # 3 hours + for count in range(CYCLES): _logger.info(f"Running loop {count}") solar_calls(client) sleep(sleep_time) # scan_interval @@ -45,39 +56,59 @@ def main(): _logger.info("### End of Program") -def solar_calls(client): - """Test connection works.""" - for addr, count in ( - (32008, 1), - (32009, 1), - (32010, 1), - (32016, 1), - (32017, 1), - (32018, 1), - (32019, 1), - (32064, 2), - (32078, 2), - (32080, 2), - (32114, 2), - (37113, 2), - (32078, 2), - (32078, 2), +def solar_calls(client: ModbusTcpClient) -> None: + """Read registers.""" + error = False + + for addr, format, factor, comment, unit in ( # data_type according to ModbusClientMixin.DATATYPE.value[0] + (32008, "H", 1, "Alarm 1", "(bitfield)"), + (32009, "H", 1, "Alarm 2", "(bitfield)"), + (32010, "H", 1, "Alarm 3", "(bitfield)"), + (32016, "h", 0.1, "PV 1 voltage", "V"), + (32017, "h", 0.01, "PV 1 current", "A"), + (32018, "h", 0.1, "PV 2 voltage", "V"), + (32019, "h", 0.01, "PV 2 current", "A"), + (32064, "i", 0.001, "Input power", "kW"), + (32078, "i", 0.001, "Peak active power of current day", "kW"), + (32080, "i", 0.001, "Active power", "kW"), + (37114, "I", 0.001, "Daily energy yield", "kW"), ): - lazy_error_count = 15 - while lazy_error_count > 0: - try: - rr = client.read_coils(addr, count, slave=1) - except ModbusException as exc: - _logger.debug(f"TEST: exception lazy({lazy_error_count}) {exc}") - lazy_error_count -= 1 - continue - if not hasattr(rr, "registers"): - _logger.debug(f"TEST: no registers lazy({lazy_error_count})") - lazy_error_count -= 1 - continue - break - if not lazy_error_count: - raise RuntimeError("HARD ERROR, more than 15 retries!") + if error: + error = False + client.close() + sleep(0.1) + client.connect() + sleep(1) + + data_type = get_data_type(format) + count = data_type.value[1] + + _logger.info(f"*** Reading {comment}") + + try: + rr = client.read_holding_registers(address=addr, count=count, slave=1) + except ModbusException as exc: + _logger.error(f"Modbus exception: {exc!s}") + error = True + continue + if rr.isError(): + _logger.error(f"Error") + error = True + continue + if isinstance(rr, ExceptionResponse): + _logger.error(f"Response exception: {rr!s}") + error = True + continue + + value = client.convert_from_registers(rr.registers, data_type) * factor + _logger.info(f"*** READ *** {comment} = {value} {unit}") + + +def get_data_type(format: str) -> Enum: + """Return the ModbusTcpClient.DATATYPE according to the format""" + for data_type in ModbusTcpClient.DATATYPE: + if data_type.value[0] == format: + return data_type if __name__ == "__main__": From 4f794f49186cbd21dbb91bf9cd74eb1956ec15ff Mon Sep 17 00:00:00 2001 From: Michel F <80367602+MrWaloo@users.noreply.github.com> Date: Fri, 13 Sep 2024 08:09:22 +0200 Subject: [PATCH 07/41] Improvements for example/contib/solar (#2318) --- examples/contrib/solar.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/examples/contrib/solar.py b/examples/contrib/solar.py index a9f726993..3106317eb 100755 --- a/examples/contrib/solar.py +++ b/examples/contrib/solar.py @@ -9,6 +9,7 @@ """ import logging from enum import Enum +from math import log10 from time import sleep from pymodbus import pymodbus_apply_logging_config @@ -41,17 +42,16 @@ def main() -> None: port=PORT, # Common optional parameters: framer=FramerType.SOCKET, - timeout=1, + timeout=5, ) client.connect() _logger.info("### Client connected") sleep(1) _logger.info("### Client starting") - sleep_time = 2 for count in range(CYCLES): _logger.info(f"Running loop {count}") solar_calls(client) - sleep(sleep_time) # scan_interval + sleep(10) # scan interval client.close() _logger.info("### End of Program") @@ -61,17 +61,17 @@ def solar_calls(client: ModbusTcpClient) -> None: error = False for addr, format, factor, comment, unit in ( # data_type according to ModbusClientMixin.DATATYPE.value[0] - (32008, "H", 1, "Alarm 1", "(bitfield)"), - (32009, "H", 1, "Alarm 2", "(bitfield)"), - (32010, "H", 1, "Alarm 3", "(bitfield)"), - (32016, "h", 0.1, "PV 1 voltage", "V"), - (32017, "h", 0.01, "PV 1 current", "A"), - (32018, "h", 0.1, "PV 2 voltage", "V"), - (32019, "h", 0.01, "PV 2 current", "A"), - (32064, "i", 0.001, "Input power", "kW"), + (32008, "H", 1, "Alarm 1", "(bitfield)"), + (32009, "H", 1, "Alarm 2", "(bitfield)"), + (32010, "H", 1, "Alarm 3", "(bitfield)"), + (32016, "h", 0.1, "PV 1 voltage", "V"), + (32017, "h", 0.01, "PV 1 current", "A"), + (32018, "h", 0.1, "PV 2 voltage", "V"), + (32019, "h", 0.01, "PV 2 current", "A"), + (32064, "i", 0.001, "Input power", "kW"), (32078, "i", 0.001, "Peak active power of current day", "kW"), - (32080, "i", 0.001, "Active power", "kW"), - (37114, "I", 0.001, "Daily energy yield", "kW"), + (32080, "i", 0.001, "Active power", "kW"), + (32114, "I", 0.001, "Daily energy yield", "kWh"), ): if error: error = False @@ -82,8 +82,9 @@ def solar_calls(client: ModbusTcpClient) -> None: data_type = get_data_type(format) count = data_type.value[1] + var_type = data_type.name - _logger.info(f"*** Reading {comment}") + _logger.info(f"*** Reading {comment} ({var_type})") try: rr = client.read_holding_registers(address=addr, count=count, slave=1) @@ -101,6 +102,8 @@ def solar_calls(client: ModbusTcpClient) -> None: continue value = client.convert_from_registers(rr.registers, data_type) * factor + if factor < 1: + value = round(value, int(log10(factor) * -1)) _logger.info(f"*** READ *** {comment} = {value} {unit}") From aa7e556cb53c4270c4e0b74b771ba430332c62e8 Mon Sep 17 00:00:00 2001 From: Justin Standring Date: Sun, 15 Sep 2024 02:31:06 +1200 Subject: [PATCH 08/41] Fix encoding & decoding of ReadFileRecordResponse (#2319) --- pymodbus/pdu/file_message.py | 9 ++++++--- test/test_file_message.py | 15 ++++++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/pymodbus/pdu/file_message.py b/pymodbus/pdu/file_message.py index 37adbd5b6..954b4e89f 100644 --- a/pymodbus/pdu/file_message.py +++ b/pymodbus/pdu/file_message.py @@ -170,7 +170,7 @@ def encode(self): total = sum(record.response_length + 1 for record in self.records) packet = struct.pack("B", total) for record in self.records: - packet += struct.pack(">BB", record.record_length, 0x06) + packet += struct.pack(">BB", record.response_length, 0x06) packet += record.record_data return packet @@ -185,11 +185,14 @@ def decode(self, data): response_length, reference_type = struct.unpack( ">BB", data[count : count + 2] ) - count += response_length + 1 # the count is not included + count += 2 + + record_length = response_length - 1 # response length includes the type byte record = FileRecord( response_length=response_length, - record_data=data[count - response_length + 1 : count], + record_data=data[count : count + record_length], ) + count += record_length if reference_type == 0x06: self.records.append(record) diff --git a/test/test_file_message.py b/test/test_file_message.py index 36ef6ee0e..ab2aae876 100644 --- a/test/test_file_message.py +++ b/test/test_file_message.py @@ -163,17 +163,22 @@ def test_read_file_record_response_encode(self): records = [FileRecord(record_data=b"\x00\x01\x02\x03\x04\x05")] handle = ReadFileRecordResponse(records) result = handle.encode() - assert result == b"\x08\x03\x06\x00\x01\x02\x03\x04\x05" + assert result == b"\x08\x07\x06\x00\x01\x02\x03\x04\x05" def test_read_file_record_response_decode(self): """Test basic bit message encoding/decoding.""" - record = FileRecord( + record1 = FileRecord( file_number=0x00, record_number=0x00, record_data=b"\x0d\xfe\x00\x20" ) - request = b"\x0c\x05\x06\x0d\xfe\x00\x20\x05\x05\x06\x33\xcd\x00\x40" + record2 = FileRecord( + file_number=0x00, record_number=0x00, record_data=b"\x33\xcd\x00\x40" + ) + response = b"\x0c\x05\x06\x0d\xfe\x00\x20\x05\x06\x33\xcd\x00\x40" handle = ReadFileRecordResponse() - handle.decode(request) - assert handle.records[0] == record + handle.decode(response) + + assert handle.records[0] == record1 + assert handle.records[1] == record2 def test_read_file_record_response_rtu_frame_size(self): """Test basic bit message encoding/decoding.""" From 3cc510ca0782c26b35abb9f83e10fc2740a8d2e9 Mon Sep 17 00:00:00 2001 From: Matthias Urlichs Date: Mon, 23 Sep 2024 10:35:05 +0200 Subject: [PATCH 09/41] Add `stacklevel=2` to logging functions (#2330) --- pymodbus/logging.py | 10 +++++----- pyproject.toml | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pymodbus/logging.py b/pymodbus/logging.py index aec7e0599..764eab215 100644 --- a/pymodbus/logging.py +++ b/pymodbus/logging.py @@ -94,28 +94,28 @@ def build_msg(cls, txt, *args): def info(cls, txt, *args): """Log info messages.""" if cls._logger.isEnabledFor(logging.INFO): - cls._logger.info(cls.build_msg(txt, *args)) + cls._logger.info(cls.build_msg(txt, *args), stacklevel=2) @classmethod def debug(cls, txt, *args): """Log debug messages.""" if cls._logger.isEnabledFor(logging.DEBUG): - cls._logger.debug(cls.build_msg(txt, *args)) + cls._logger.debug(cls.build_msg(txt, *args), stacklevel=2) @classmethod def warning(cls, txt, *args): """Log warning messages.""" if cls._logger.isEnabledFor(logging.WARNING): - cls._logger.warning(cls.build_msg(txt, *args)) + cls._logger.warning(cls.build_msg(txt, *args), stacklevel=2) @classmethod def error(cls, txt, *args): """Log error messages.""" if cls._logger.isEnabledFor(logging.ERROR): - cls._logger.error(cls.build_msg(txt, *args)) + cls._logger.error(cls.build_msg(txt, *args), stacklevel=2) @classmethod def critical(cls, txt, *args): """Log critical messages.""" if cls._logger.isEnabledFor(logging.CRITICAL): - cls._logger.critical(cls.build_msg(txt, *args)) + cls._logger.critical(cls.build_msg(txt, *args), stacklevel=2) diff --git a/pyproject.toml b/pyproject.toml index 4c3bbba50..2f255f301 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ classifiers = [ "Operating System :: OS Independent", "Operating System :: Microsoft", "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", From fd87d4ebd8e999dc0794f0372a3bd3ea4acbfd89 Mon Sep 17 00:00:00 2001 From: Matthias Urlichs Date: Mon, 23 Sep 2024 10:35:44 +0200 Subject: [PATCH 10/41] Forward error responses instead of timing out. (#2329) --- pymodbus/pdu/register_read_message.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pymodbus/pdu/register_read_message.py b/pymodbus/pdu/register_read_message.py index c647c2ef0..4f0640b9f 100644 --- a/pymodbus/pdu/register_read_message.py +++ b/pymodbus/pdu/register_read_message.py @@ -6,6 +6,7 @@ from pymodbus.exceptions import ModbusIOException from pymodbus.pdu import ModbusExceptions as merror +from pymodbus.pdu import ExceptionResponse from pymodbus.pdu import ModbusRequest, ModbusResponse @@ -146,6 +147,8 @@ async def execute(self, context): values = await context.async_getValues( self.function_code, self.address, self.count ) + if isinstance(values, ExceptionResponse): + return values return ReadHoldingRegistersResponse(values) @@ -206,6 +209,8 @@ async def execute(self, context): values = await context.async_getValues( self.function_code, self.address, self.count ) + if isinstance(values, ExceptionResponse): + return values return ReadInputRegistersResponse(values) @@ -327,6 +332,8 @@ async def execute(self, context): registers = await context.async_getValues( self.function_code, self.read_address, self.read_count ) + if isinstance(registers, ExceptionResponse): + return registers return ReadWriteMultipleRegistersResponse(registers) def get_response_pdu_size(self): From 04413d09a6fc5ecb4a1faed6b615dd7ae6f40b95 Mon Sep 17 00:00:00 2001 From: Marko Luther Date: Mon, 23 Sep 2024 10:37:06 +0200 Subject: [PATCH 11/41] Fixes the unexpected implementation of the ModbusSerialClient.connected property (#2327) --- pymodbus/client/serial.py | 6 +++--- pymodbus/client/tcp.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pymodbus/client/serial.py b/pymodbus/client/serial.py index c48e9f43b..967c89c15 100644 --- a/pymodbus/client/serial.py +++ b/pymodbus/client/serial.py @@ -208,9 +208,9 @@ def __init__( # pylint: disable=too-many-arguments self.silent_interval = round(self.silent_interval, 6) @property - def connected(self): - """Connect internal.""" - return self.connect() + def connected(self) -> bool: + """Check if socket exists.""" + return self.socket is not None def connect(self) -> bool: """Connect to the modbus serial server.""" diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py index ca40ae92a..96490d14b 100644 --- a/pymodbus/client/tcp.py +++ b/pymodbus/client/tcp.py @@ -160,7 +160,7 @@ def __init__( @property def connected(self) -> bool: - """Connect internal.""" + """Check if socket exists.""" return self.socket is not None def connect(self): From e9ee7c3af363022067ad753d38096fcfb43b02f5 Mon Sep 17 00:00:00 2001 From: Michel F <80367602+MrWaloo@users.noreply.github.com> Date: Mon, 23 Sep 2024 10:38:03 +0200 Subject: [PATCH 12/41] Documentation on_connect_callback (#2324) --- pymodbus/client/serial.py | 2 +- pymodbus/client/tcp.py | 2 +- pymodbus/client/tls.py | 2 +- pymodbus/client/udp.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pymodbus/client/serial.py b/pymodbus/client/serial.py index 967c89c15..d307e263f 100644 --- a/pymodbus/client/serial.py +++ b/pymodbus/client/serial.py @@ -39,7 +39,7 @@ class AsyncModbusSerialClient(ModbusBaseClient): :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. :param timeout: Timeout for a connection request, in seconds. :param retries: Max number of retries per request. - :param on_reconnect_callback: Function that will be called just before a reconnection attempt. + :param on_connect_callback: Function that will be called just before a connection attempt. .. tip:: **reconnect_delay** doubles automatically with each unsuccessful connect, from diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py index 96490d14b..20b527885 100644 --- a/pymodbus/client/tcp.py +++ b/pymodbus/client/tcp.py @@ -30,7 +30,7 @@ class AsyncModbusTcpClient(ModbusBaseClient): :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. :param timeout: Timeout for a connection request, in seconds. :param retries: Max number of retries per request. - :param on_reconnect_callback: Function that will be called just before a reconnection attempt. + :param on_connect_callback: Function that will be called just before a connection attempt. .. tip:: **reconnect_delay** doubles automatically with each unsuccessful connect, from diff --git a/pymodbus/client/tls.py b/pymodbus/client/tls.py index deddd6bc2..9c3735600 100644 --- a/pymodbus/client/tls.py +++ b/pymodbus/client/tls.py @@ -29,7 +29,7 @@ class AsyncModbusTlsClient(AsyncModbusTcpClient): :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. :param timeout: Timeout for a connection request, in seconds. :param retries: Max number of retries per request. - :param on_reconnect_callback: Function that will be called just before a reconnection attempt. + :param on_connect_callback: Function that will be called just before a connection attempt. .. tip:: **reconnect_delay** doubles automatically with each unsuccessful connect, from diff --git a/pymodbus/client/udp.py b/pymodbus/client/udp.py index 48b71edab..6d36f93dc 100644 --- a/pymodbus/client/udp.py +++ b/pymodbus/client/udp.py @@ -31,7 +31,7 @@ class AsyncModbusUdpClient(ModbusBaseClient): :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. :param timeout: Timeout for a connection request, in seconds. :param retries: Max number of retries per request. - :param on_reconnect_callback: Function that will be called just before a reconnection attempt. + :param on_connect_callback: Function that will be called just before a connection attempt. .. tip:: **reconnect_delay** doubles automatically with each unsuccessful connect, from From bcd546b89e8c5f067c019418106a902bf897ec89 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Sep 2024 13:44:25 +0200 Subject: [PATCH 13/41] Bump 3rd party (#2331) --- pymodbus/pdu/register_read_message.py | 3 +-- pyproject.toml | 17 +++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pymodbus/pdu/register_read_message.py b/pymodbus/pdu/register_read_message.py index 4f0640b9f..7d05e6ed2 100644 --- a/pymodbus/pdu/register_read_message.py +++ b/pymodbus/pdu/register_read_message.py @@ -5,9 +5,8 @@ import struct from pymodbus.exceptions import ModbusIOException +from pymodbus.pdu import ExceptionResponse, ModbusRequest, ModbusResponse from pymodbus.pdu import ModbusExceptions as merror -from pymodbus.pdu import ExceptionResponse -from pymodbus.pdu import ModbusRequest, ModbusResponse class ReadRegistersRequestBase(ModbusRequest): diff --git a/pyproject.toml b/pyproject.toml index 2f255f301..bfb3ce760 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ repl = [ simulator = [ "aiohttp>=3.8.6;python_version<'3.12'", - "aiohttp>=3.9.5;python_version=='3.12'" + "aiohttp>=3.10.5;python_version=='3.12'" ] documentation = [ "recommonmark>=0.7.1", @@ -59,13 +59,13 @@ documentation = [ "sphinx-rtd-theme>=2.0.0" ] development = [ - "build>=1.2.1", + "build>=1.2.2", "codespell>=2.3.0", - "coverage>=7.6.0", - "mypy>=1.10.1", - "pylint>=3.2.5", - "pytest>=8.2.2", - "pytest-asyncio>=0.23.8", + "coverage>=7.6.1", + "mypy>=1.11.2", + "pylint>=3.3.0", + "pytest>=8.3.3", + "pytest-asyncio>=0.24.0", "pytest-cov>=5.0.0", "pytest-profiling>=1.7.0", "pytest-timeout>=2.3.1", @@ -163,7 +163,8 @@ attr-rgx = "([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$" method-rgx = "[a-z_][a-zA-Z0-9_]{2,}$" [tool.pylint.design] -max-args = "10" +max-positional = 15 +max-args = 10 max-locals = "25" max-returns = "11" max-branches = "27" From cf7c6a9c834e9e727f8fb5a2c275d7fea9805876 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 23 Sep 2024 16:16:50 +0200 Subject: [PATCH 14/41] Update actions to new node.js. (#2332) --- .github/workflows/clean_workflow_runs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/clean_workflow_runs.yml b/.github/workflows/clean_workflow_runs.yml index 1f861d66c..98d23aab1 100644 --- a/.github/workflows/clean_workflow_runs.yml +++ b/.github/workflows/clean_workflow_runs.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clear cache - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | console.log("About to clear") From 2d51f8c82a90624776b3c82c400c24a903d3b530 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Sep 2024 09:59:02 +0200 Subject: [PATCH 15/41] transport 100% test coverage (again) (#2333) --- test/transport/test_comm.py | 2 +- test/transport/test_serial.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/test/transport/test_comm.py b/test/transport/test_comm.py index 35b25c52c..141b4c8d3 100644 --- a/test/transport/test_comm.py +++ b/test/transport/test_comm.py @@ -174,7 +174,7 @@ async def test_split_serial_packet(self, client, server, use_port): ) async def test_serial_poll(self, client, server, use_port): """Test connection and data exchange.""" - if SerialTransport.force_poll: + if SerialTransport.force_poll: # pragma: no cover client.close() server.close() return diff --git a/test/transport/test_serial.py b/test/transport/test_serial.py index 702c0d441..0d7e7497d 100644 --- a/test/transport/test_serial.py +++ b/test/transport/test_serial.py @@ -2,6 +2,7 @@ import asyncio import contextlib import os +import sys from functools import partial from unittest import mock @@ -81,7 +82,7 @@ async def test_create_serial(self): async def test_force_poll(self): """Test external methods.""" - if SerialTransport.force_poll: + if SerialTransport.force_poll: # pragma: no cover return SerialTransport.force_poll = True transport, protocol = await create_serial_connection( @@ -93,10 +94,9 @@ async def test_force_poll(self): transport.close() SerialTransport.force_poll = False - async def test_write_force_poll(self): """Test write with poll.""" - if SerialTransport.force_poll: + if SerialTransport.force_poll: # pragma: no cover return SerialTransport.force_poll = True transport, protocol = await create_serial_connection( @@ -195,3 +195,10 @@ async def test_read_ready(self): comm.sync_serial.read.return_value = b'abcd' comm.intern_read_ready() comm.intern_protocol.data_received.assert_called_once() + + async def test_import_pyserial(self): + """Test pyserial not installed.""" + with mock.patch.dict(sys.modules, {'no_modules': None}) as mock_modules: + del mock_modules['serial'] + with pytest.raises(RuntimeError): + SerialTransport(asyncio.get_running_loop(), mock.Mock(), "dummy", None, None, None, None, None) From 832240220dd239c4feb89d2bf0c448a41e105071 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Sep 2024 09:59:40 +0200 Subject: [PATCH 16/41] Remove asyncio_default_fixture_loop_scope (deprecated). (#2334) --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bfb3ce760..5bdacf6fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,7 +219,6 @@ all_files = "1" testpaths = ["test"] addopts = "--cov-report html --durations=10 --dist loadscope --numprocesses auto" asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" timeout = 120 [tool.coverage.run] From 42fef3b08ca17464a39c1c32ff53cb7ba3567e01 Mon Sep 17 00:00:00 2001 From: Andy Walker <16687934+camtarn@users.noreply.github.com> Date: Tue, 24 Sep 2024 10:42:38 +0100 Subject: [PATCH 17/41] Improve docs around sync clients and reconnection (#2321) Co-authored-by: Andy Walker Co-authored-by: jan iversen --- doc/source/client.rst | 24 ++++++++++++++++++++---- pymodbus/client/serial.py | 14 ++++++-------- pymodbus/client/tcp.py | 13 ++++++------- pymodbus/client/tls.py | 13 ++++++------- pymodbus/client/udp.py | 13 ++++++------- 5 files changed, 44 insertions(+), 33 deletions(-) diff --git a/doc/source/client.rst b/doc/source/client.rst index cec456a5b..ec71e15b9 100644 --- a/doc/source/client.rst +++ b/doc/source/client.rst @@ -118,19 +118,32 @@ that a device have received the packet. Client usage ------------ Using pymodbus client to set/get information from a device (server) -is done in a few simple steps, like the following synchronous example:: +is done in a few simple steps. + +Synchronous example +^^^^^^^^^^^^^^^^^^^ + +:: from pymodbus.client import ModbusTcpClient client = ModbusTcpClient('MyDevice.lan') # Create client object - client.connect() # connect to device, reconnect automatically + client.connect() # connect to device client.write_coil(1, True, slave=1) # set information in device result = client.read_coils(2, 3, slave=1) # get information from device print(result.bits[0]) # use information client.close() # Disconnect device +The line :mod:`client.connect()` connects to the device (or comm port). If this cannot connect successfully within +the timeout it throws an exception. After this initial connection, further +calls to the same client (here, :mod:`client.write_coil(...)` and +:mod:`client.read_coils(...)` ) will check whether the client is still +connected, and automatically reconnect if not. -and a asynchronous example:: +Asynchronous example +^^^^^^^^^^^^^^^^^^^^ + +:: from pymodbus.client import AsyncModbusTcpClient @@ -141,7 +154,7 @@ and a asynchronous example:: print(result.bits[0]) # use information client.close() # Disconnect device -The line :mod:`client = AsyncModbusTcpClient('MyDevice.lan')` only creates the object it does not activate +The line :mod:`client = AsyncModbusTcpClient('MyDevice.lan')` only creates the object; it does not activate anything. The line :mod:`await client.connect()` connects to the device (or comm port), if this cannot connect successfully within @@ -153,6 +166,9 @@ The line :mod:`result = await client.read_coils(2, 3, slave=1)` is an example of The last line :mod:`client.close()` closes the connection and render the object inactive. +Development notes +^^^^^^^^^^^^^^^^^ + Large parts of the implementation are shared between the different classes, to ensure high stability and efficient maintenance. diff --git a/pymodbus/client/serial.py b/pymodbus/client/serial.py index d307e263f..2da85a2ba 100644 --- a/pymodbus/client/serial.py +++ b/pymodbus/client/serial.py @@ -123,15 +123,15 @@ class ModbusSerialClient(ModbusBaseSyncClient): :param stopbits: Number of stop bits 0-2. :param handle_local_echo: Discard local echo from dongle. :param name: Set communication name, used in logging - :param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting. - :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. + :param reconnect_delay: Not used in the sync client + :param reconnect_delay_max: Not used in the sync client :param timeout: Timeout for a connection request, in seconds. :param retries: Max number of retries per request. - .. tip:: - **reconnect_delay** doubles automatically with each unsuccessful connect, from - **reconnect_delay** to **reconnect_delay_max**. - Set `reconnect_delay=0` to avoid automatic reconnection. + Note that unlike the async client, the sync client does not perform + retries. If the connection has closed, the client will attempt to reconnect + once before executing each read/write request, and will raise a + ConnectionException if this fails. Example:: @@ -145,8 +145,6 @@ def run(): client.close() Please refer to :ref:`Pymodbus internals` for advanced usage. - - Remark: There are no automatic reconnect as with AsyncModbusSerialClient """ state = ModbusTransactionState.IDLE diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py index 20b527885..0a431a5d2 100644 --- a/pymodbus/client/tcp.py +++ b/pymodbus/client/tcp.py @@ -103,15 +103,16 @@ class ModbusTcpClient(ModbusBaseSyncClient): :param port: Port used for communication :param name: Set communication name, used in logging :param source_address: source address of client - :param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting. - :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. + :param reconnect_delay: Not used in the sync client + :param reconnect_delay_max: Not used in the sync client :param timeout: Timeout for a connection request, in seconds. :param retries: Max number of retries per request. .. tip:: - **reconnect_delay** doubles automatically with each unsuccessful connect, from - **reconnect_delay** to **reconnect_delay_max**. - Set `reconnect_delay=0` to avoid automatic reconnection. + Unlike the async client, the sync client does not perform + retries. If the connection has closed, the client will attempt to reconnect + once before executing each read/write request, and will raise a + ConnectionException if this fails. Example:: @@ -125,8 +126,6 @@ async def run(): client.close() Please refer to :ref:`Pymodbus internals` for advanced usage. - - Remark: There are no automatic reconnect as with AsyncModbusTcpClient """ socket: socket.socket | None diff --git a/pymodbus/client/tls.py b/pymodbus/client/tls.py index 9c3735600..55a224fb1 100644 --- a/pymodbus/client/tls.py +++ b/pymodbus/client/tls.py @@ -120,15 +120,16 @@ class ModbusTlsClient(ModbusTcpClient): :param port: Port used for communication :param name: Set communication name, used in logging :param source_address: Source address of client - :param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting. - :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. + :param reconnect_delay: Not used in the sync client + :param reconnect_delay_max: Not used in the sync client :param timeout: Timeout for a connection request, in seconds. :param retries: Max number of retries per request. .. tip:: - **reconnect_delay** doubles automatically with each unsuccessful connect, from - **reconnect_delay** to **reconnect_delay_max**. - Set `reconnect_delay=0` to avoid automatic reconnection. + Unlike the async client, the sync client does not perform + retries. If the connection has closed, the client will attempt to reconnect + once before executing each read/write request, and will raise a + ConnectionException if this fails. Example:: @@ -142,8 +143,6 @@ async def run(): client.close() Please refer to :ref:`Pymodbus internals` for advanced usage. - - Remark: There are no automatic reconnect as with AsyncModbusTlsClient """ def __init__( # pylint: disable=too-many-arguments diff --git a/pymodbus/client/udp.py b/pymodbus/client/udp.py index 6d36f93dc..23c6db759 100644 --- a/pymodbus/client/udp.py +++ b/pymodbus/client/udp.py @@ -103,15 +103,16 @@ class ModbusUdpClient(ModbusBaseSyncClient): :param port: Port used for communication. :param name: Set communication name, used in logging :param source_address: source address of client, - :param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting. - :param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting. + :param reconnect_delay: Not used in the sync client + :param reconnect_delay_max: Not used in the sync client :param timeout: Timeout for a connection request, in seconds. :param retries: Max number of retries per request. .. tip:: - **reconnect_delay** doubles automatically with each unsuccessful connect, from - **reconnect_delay** to **reconnect_delay_max**. - Set `reconnect_delay=0` to avoid automatic reconnection. + Unlike the async client, the sync client does not perform + retries. If the connection has closed, the client will attempt to reconnect + once before executing each read/write request, and will raise a + ConnectionException if this fails. Example:: @@ -125,8 +126,6 @@ async def run(): client.close() Please refer to :ref:`Pymodbus internals` for advanced usage. - - Remark: There are no automatic reconnect as with AsyncModbusUdpClient """ socket: socket.socket | None From dd196d65b476a74f891464bac612366390e6ad59 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Sep 2024 12:03:48 +0200 Subject: [PATCH 18/41] Regroup test. (#2335) --- test/{ => sub_current}/test_device.py | 0 test/{ => sub_current}/test_events.py | 0 test/{ => sub_current}/test_exceptions.py | 0 test/{ => sub_current}/test_factory.py | 0 test/{ => sub_current}/test_file_message.py | 0 test/{ => sub_current}/test_logging.py | 0 test/{ => sub_current}/test_network.py | 0 test/{ => sub_current}/test_payload.py | 0 test/{ => sub_current}/test_pdu.py | 0 test/{ => sub_current}/test_remote_datastore.py | 0 test/{ => sub_current}/test_sparse_datastore.py | 0 test/{ => sub_current}/test_transaction.py | 0 test/{ => sub_current}/test_utilities.py | 0 13 files changed, 0 insertions(+), 0 deletions(-) rename test/{ => sub_current}/test_device.py (100%) rename test/{ => sub_current}/test_events.py (100%) rename test/{ => sub_current}/test_exceptions.py (100%) rename test/{ => sub_current}/test_factory.py (100%) rename test/{ => sub_current}/test_file_message.py (100%) rename test/{ => sub_current}/test_logging.py (100%) rename test/{ => sub_current}/test_network.py (100%) rename test/{ => sub_current}/test_payload.py (100%) rename test/{ => sub_current}/test_pdu.py (100%) rename test/{ => sub_current}/test_remote_datastore.py (100%) rename test/{ => sub_current}/test_sparse_datastore.py (100%) rename test/{ => sub_current}/test_transaction.py (100%) rename test/{ => sub_current}/test_utilities.py (100%) diff --git a/test/test_device.py b/test/sub_current/test_device.py similarity index 100% rename from test/test_device.py rename to test/sub_current/test_device.py diff --git a/test/test_events.py b/test/sub_current/test_events.py similarity index 100% rename from test/test_events.py rename to test/sub_current/test_events.py diff --git a/test/test_exceptions.py b/test/sub_current/test_exceptions.py similarity index 100% rename from test/test_exceptions.py rename to test/sub_current/test_exceptions.py diff --git a/test/test_factory.py b/test/sub_current/test_factory.py similarity index 100% rename from test/test_factory.py rename to test/sub_current/test_factory.py diff --git a/test/test_file_message.py b/test/sub_current/test_file_message.py similarity index 100% rename from test/test_file_message.py rename to test/sub_current/test_file_message.py diff --git a/test/test_logging.py b/test/sub_current/test_logging.py similarity index 100% rename from test/test_logging.py rename to test/sub_current/test_logging.py diff --git a/test/test_network.py b/test/sub_current/test_network.py similarity index 100% rename from test/test_network.py rename to test/sub_current/test_network.py diff --git a/test/test_payload.py b/test/sub_current/test_payload.py similarity index 100% rename from test/test_payload.py rename to test/sub_current/test_payload.py diff --git a/test/test_pdu.py b/test/sub_current/test_pdu.py similarity index 100% rename from test/test_pdu.py rename to test/sub_current/test_pdu.py diff --git a/test/test_remote_datastore.py b/test/sub_current/test_remote_datastore.py similarity index 100% rename from test/test_remote_datastore.py rename to test/sub_current/test_remote_datastore.py diff --git a/test/test_sparse_datastore.py b/test/sub_current/test_sparse_datastore.py similarity index 100% rename from test/test_sparse_datastore.py rename to test/sub_current/test_sparse_datastore.py diff --git a/test/test_transaction.py b/test/sub_current/test_transaction.py similarity index 100% rename from test/test_transaction.py rename to test/sub_current/test_transaction.py diff --git a/test/test_utilities.py b/test/sub_current/test_utilities.py similarity index 100% rename from test/test_utilities.py rename to test/sub_current/test_utilities.py From e315f71bc5aa47ca51fcfc570e76191545cbc58e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 24 Sep 2024 12:30:24 +0200 Subject: [PATCH 19/41] Revert "Remove asyncio_default_fixture_loop_scope (deprecated). (#2334)" (#2336) This reverts commit 832240220dd239c4feb89d2bf0c448a41e105071. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 5bdacf6fe..bfb3ce760 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,6 +219,7 @@ all_files = "1" testpaths = ["test"] addopts = "--cov-report html --durations=10 --dist loadscope --numprocesses auto" asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" timeout = 120 [tool.coverage.run] From cca6254e1aecf56b92a9d46e186767eb019807f0 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 27 Sep 2024 08:55:53 +0200 Subject: [PATCH 20/41] Reopen listener in server if disconnected. (#2337) --- pymodbus/transport/transport.py | 35 ++++++++++++++++++-------------- test/transport/test_protocol.py | 2 -- test/transport/test_reconnect.py | 31 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/pymodbus/transport/transport.py b/pymodbus/transport/transport.py index 8ec1216e5..34bd86a7b 100644 --- a/pymodbus/transport/transport.py +++ b/pymodbus/transport/transport.py @@ -152,16 +152,12 @@ def __init__( self.loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() self.recv_buffer: bytes = b"" self.call_create: Callable[[], Coroutine[Any, Any, Any]] = None # type: ignore[assignment] - if self.is_server: - self.active_connections: dict[str, ModbusProtocol] = {} - else: - self.listener: ModbusProtocol | None = None - self.unique_id: str = str(id(self)) - self.reconnect_task: asyncio.Task | None = None - self.reconnect_delay_current = 0.0 - self.sent_buffer: bytes = b"" - - # ModbusProtocol specific setup + self.reconnect_task: asyncio.Task | None = None + self.listener: ModbusProtocol | None = None + self.active_connections: dict[str, ModbusProtocol] = {} + self.unique_id: str = str(id(self)) + self.reconnect_delay_current = 0.0 + self.sent_buffer: bytes = b"" if self.is_server: if self.comm_params.source_address is not None: host = self.comm_params.source_address[0] @@ -285,11 +281,10 @@ def connection_lost(self, reason: Exception | None) -> None: return Log.debug("Connection lost {} due to {}", self.comm_params.comm_name, reason) self.__close() - if ( - not self.is_server - and not self.listener - and self.comm_params.reconnect_delay - ): + if self.is_server: + self.reconnect_task = asyncio.create_task(self.do_relisten()) + self.reconnect_task.set_name("transport relisten") + elif not self.listener and self.comm_params.reconnect_delay: self.reconnect_task = asyncio.create_task(self.do_reconnect()) self.reconnect_task.set_name("transport reconnect") self.callback_disconnected(reason) @@ -459,6 +454,16 @@ def handle_new_connection(self) -> ModbusProtocol: new_protocol.listener = self return new_protocol + async def do_relisten(self) -> None: + """Handle reconnect as a task.""" + try: + Log.debug("Wait 1s before reopening listener.") + await asyncio.sleep(1) + await self.listen() + except asyncio.CancelledError: + pass + self.reconnect_task = None + async def do_reconnect(self) -> None: """Handle reconnect as a task.""" try: diff --git a/test/transport/test_protocol.py b/test/transport/test_protocol.py index 7aafc3777..f53149e5f 100644 --- a/test/transport/test_protocol.py +++ b/test/transport/test_protocol.py @@ -32,14 +32,12 @@ def get_port_in_class(base_ports): @pytest.mark.parametrize("use_comm_type", COMM_TYPES) async def test_init_client(self, client): """Test init client.""" - assert not hasattr(client, "active_connections") assert not client.is_server @pytest.mark.parametrize("use_comm_type", COMM_TYPES) async def test_init_server(self, server): """Test init server.""" - assert not hasattr(server, "unique_id") assert not server.active_connections assert server.is_server diff --git a/test/transport/test_reconnect.py b/test/transport/test_reconnect.py index 31d9960c6..a54e4c12c 100644 --- a/test/transport/test_reconnect.py +++ b/test/transport/test_reconnect.py @@ -4,6 +4,8 @@ import pytest +from pymodbus.transport import NULLMODEM_HOST, CommType + class TestTransportReconnect: """Test transport module, base part.""" @@ -70,3 +72,32 @@ async def test_reconnect_call_ok(self, client): assert client.reconnect_delay_current == client.comm_params.reconnect_delay assert not client.reconnect_task client.close() + + @pytest.mark.parametrize( + ("use_comm_type", "use_host"), + [ + (CommType.TCP, "localhost"), + (CommType.TLS, "localhost"), + (CommType.UDP, "localhost"), + (CommType.SERIAL, NULLMODEM_HOST), + ], + ) + async def test_listen_disconnect(self, server): + """Test listen().""" + assert await server.listen() + assert server.transport + server.connection_lost(None) + assert not server.transport + await asyncio.sleep(1.5) + assert server.transport + server.close() + assert not server.transport + + async def test_relisten_call(self, server): + """Test connection_lost().""" + server.loop = asyncio.get_running_loop() + await server.listen() + server.connection_lost(RuntimeError("Listener disconnected lost")) + assert server.reconnect_task + server.close() + From ae140ce2ba012848c1f8da62bd56233ce2d6355b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 27 Sep 2024 09:04:26 +0200 Subject: [PATCH 21/41] Server listener and client connections have is_server set. (#2338) --- pymodbus/server/async_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymodbus/server/async_io.py b/pymodbus/server/async_io.py index d0c511bf4..593a4d254 100644 --- a/pymodbus/server/async_io.py +++ b/pymodbus/server/async_io.py @@ -43,7 +43,7 @@ def __init__(self, owner): host=owner.comm_params.source_address[0], port=owner.comm_params.source_address[1], ) - super().__init__(params, False) + super().__init__(params, True) self.server = owner self.running = False self.receive_queue: asyncio.Queue = asyncio.Queue() From 7fb458346a5212ed2f24d47f37d3d330c0992a7e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 27 Sep 2024 09:21:18 +0200 Subject: [PATCH 22/41] CI run on demand on non-protected branches. (#2339) --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b7bf1b9f..eb9a4671d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,6 @@ on: - dev - master - wait_next_API - - dev_* tags: - v* pull_request: From a6abdabf647278e2dc2a2fb560cf98399b2e6093 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 27 Sep 2024 16:37:03 +0200 Subject: [PATCH 23/41] Remove unused protocol_id from pdu (#2340) --- examples/client_custom_msg.py | 12 +++--- pymodbus/factory.py | 2 +- pymodbus/framer/old_framer_base.py | 1 - pymodbus/pdu/bit_read_message.py | 24 +++++------ pymodbus/pdu/bit_write_message.py | 16 ++++---- pymodbus/pdu/diag_message.py | 40 +++++++++---------- pymodbus/pdu/file_message.py | 24 +++++------ pymodbus/pdu/mei_message.py | 8 ++-- pymodbus/pdu/other_message.py | 32 +++++++-------- pymodbus/pdu/pdu.py | 24 +++++------ pymodbus/pdu/register_read_message.py | 28 ++++++------- pymodbus/pdu/register_write_message.py | 24 +++++------ test/framers/test_tbc_transaction.py | 14 +++---- test/sub_client/test_client.py | 8 ++-- test/sub_current/test_pdu.py | 10 ++--- test/sub_current/test_transaction.py | 18 ++++----- test/sub_function_codes/test_all_messages.py | 4 +- .../test_bit_read_messages.py | 38 +++++++++--------- 18 files changed, 157 insertions(+), 170 deletions(-) diff --git a/examples/client_custom_msg.py b/examples/client_custom_msg.py index bc44658df..67f39cce0 100755 --- a/examples/client_custom_msg.py +++ b/examples/client_custom_msg.py @@ -36,9 +36,9 @@ class CustomModbusResponse(ModbusResponse): function_code = 55 _rtu_byte_count_pos = 2 - def __init__(self, values=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, values=None, slave=1, transaction=0, skip_encode=False): """Initialize.""" - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.values = values or [] def encode(self): @@ -68,9 +68,9 @@ class CustomModbusRequest(ModbusRequest): function_code = 55 _rtu_frame_size = 8 - def __init__(self, address=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=None, slave=1, transaction=0, skip_encode=False): """Initialize.""" - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.address = address self.count = 16 @@ -100,12 +100,12 @@ def execute(self, context): class Read16CoilsRequest(ReadCoilsRequest): """Read 16 coils in one request.""" - def __init__(self, address, count=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address, count=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The address to start reading from """ - ReadCoilsRequest.__init__(self, address, count=16, slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + ReadCoilsRequest.__init__(self, address, count=16, slave=slave, transaction=transaction, skip_encode=skip_encode) # --------------------------------------------------------------------------- # diff --git a/pymodbus/factory.py b/pymodbus/factory.py index 1e4b04a06..ea5fff7a5 100644 --- a/pymodbus/factory.py +++ b/pymodbus/factory.py @@ -120,7 +120,7 @@ def _helper(self, data: str): function_code = int(data[0]) if not (request := self.lookup.get(function_code, lambda: None)()): Log.debug("Factory Request[{}]", function_code) - request = pdu.IllegalFunctionRequest(function_code, 0, 0, 0, False) + request = pdu.IllegalFunctionRequest(function_code, 0, 0, False) else: fc_string = "{}: {}".format( # pylint: disable=consider-using-f-string str(self.lookup[function_code]) # pylint: disable=use-maxsplit-arg diff --git a/pymodbus/framer/old_framer_base.py b/pymodbus/framer/old_framer_base.py index 20eec5c14..8c357f26d 100644 --- a/pymodbus/framer/old_framer_base.py +++ b/pymodbus/framer/old_framer_base.py @@ -107,7 +107,6 @@ def populateResult(self, result): """ result.slave_id = self.dev_id result.transaction_id = self.tid - result.protocol_id = 0 def processIncomingPacket(self, data: bytes, callback, slave, single=False, tid=None): """Process new packet pattern. diff --git a/pymodbus/pdu/bit_read_message.py b/pymodbus/pdu/bit_read_message.py index 4a116dc42..a277a10eb 100644 --- a/pymodbus/pdu/bit_read_message.py +++ b/pymodbus/pdu/bit_read_message.py @@ -13,14 +13,14 @@ class ReadBitsRequestBase(ModbusRequest): _rtu_frame_size = 8 - def __init__(self, address, count, slave, transaction, protocol, skip_encode): + def __init__(self, address, count, slave, transaction, skip_encode): """Initialize the read request data. :param address: The start address to read from :param count: The number of bits after "address" to read :param slave: Modbus slave slave ID """ - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.address = address self.count = count @@ -67,13 +67,13 @@ class ReadBitsResponseBase(ModbusResponse): _rtu_byte_count_pos = 2 - def __init__(self, values, slave, transaction, protocol, skip_encode): + def __init__(self, values, slave, transaction, skip_encode): """Initialize a new instance. :param values: The requested values to be returned :param slave: Modbus slave slave ID """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) #: A list of booleans representing bit values self.bits = values or [] @@ -138,14 +138,14 @@ class ReadCoilsRequest(ReadBitsRequestBase): function_code = 1 function_code_name = "read_coils" - def __init__(self, address=None, count=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The address to start reading from :param count: The number of bits to read :param slave: Modbus slave slave ID """ - ReadBitsRequestBase.__init__(self, address, count, slave, transaction, protocol, skip_encode) + ReadBitsRequestBase.__init__(self, address, count, slave, transaction, skip_encode) async def execute(self, context): """Run a read coils request against a datastore. @@ -185,13 +185,13 @@ class ReadCoilsResponse(ReadBitsResponseBase): function_code = 1 - def __init__(self, values=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, values=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param values: The request values to respond with :param slave: Modbus slave slave ID """ - ReadBitsResponseBase.__init__(self, values, slave, transaction, protocol, skip_encode) + ReadBitsResponseBase.__init__(self, values, slave, transaction, skip_encode) class ReadDiscreteInputsRequest(ReadBitsRequestBase): @@ -206,14 +206,14 @@ class ReadDiscreteInputsRequest(ReadBitsRequestBase): function_code = 2 function_code_name = "read_discrete_input" - def __init__(self, address=None, count=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The address to start reading from :param count: The number of bits to read :param slave: Modbus slave slave ID """ - ReadBitsRequestBase.__init__(self, address, count, slave, transaction, protocol, skip_encode) + ReadBitsRequestBase.__init__(self, address, count, slave, transaction, skip_encode) async def execute(self, context): """Run a read discrete input request against a datastore. @@ -253,10 +253,10 @@ class ReadDiscreteInputsResponse(ReadBitsResponseBase): function_code = 2 - def __init__(self, values=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, values=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param values: The request values to respond with :param slave: Modbus slave slave ID """ - ReadBitsResponseBase.__init__(self, values, slave, transaction, protocol, skip_encode) + ReadBitsResponseBase.__init__(self, values, slave, transaction, skip_encode) diff --git a/pymodbus/pdu/bit_write_message.py b/pymodbus/pdu/bit_write_message.py index 9b531d92c..627e7abe5 100644 --- a/pymodbus/pdu/bit_write_message.py +++ b/pymodbus/pdu/bit_write_message.py @@ -43,13 +43,13 @@ class WriteSingleCoilRequest(ModbusRequest): _rtu_frame_size = 8 - def __init__(self, address=None, value=None, slave=None, transaction=0, protocol=0, skip_encode=0): + def __init__(self, address=None, value=None, slave=None, transaction=0, skip_encode=0): """Initialize a new instance. :param address: The variable address to write :param value: The value to write at address """ - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.address = address self.value = bool(value) @@ -113,13 +113,13 @@ class WriteSingleCoilResponse(ModbusResponse): function_code = 5 _rtu_frame_size = 8 - def __init__(self, address=None, value=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=None, value=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The variable address written to :param value: The value written at address """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.address = address self.value = value @@ -167,13 +167,13 @@ class WriteMultipleCoilsRequest(ModbusRequest): function_code_name = "write_coils" _rtu_byte_count_pos = 6 - def __init__(self, address=None, values=None, slave=None, transaction=0, protocol=0, skip_encode=0): + def __init__(self, address=None, values=None, slave=None, transaction=0, skip_encode=0): """Initialize a new instance. :param address: The starting request address :param values: The values to write """ - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.address = address if values is None: values = [] @@ -250,13 +250,13 @@ class WriteMultipleCoilsResponse(ModbusResponse): function_code = 15 _rtu_frame_size = 8 - def __init__(self, address=None, count=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The starting variable address written to :param count: The number of values written """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.address = address self.count = count diff --git a/pymodbus/pdu/diag_message.py b/pymodbus/pdu/diag_message.py index e23b56a68..22e735e32 100644 --- a/pymodbus/pdu/diag_message.py +++ b/pymodbus/pdu/diag_message.py @@ -31,9 +31,9 @@ class DiagnosticStatusRequest(ModbusRequest): function_code_name = "diagnostic_status" _rtu_frame_size = 8 - def __init__(self, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, slave=1, transaction=0, skip_encode=False): """Initialize a diagnostic request.""" - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.message = None def encode(self): @@ -93,9 +93,9 @@ class DiagnosticStatusResponse(ModbusResponse): function_code = 0x08 _rtu_frame_size = 8 - def __init__(self, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, slave=1, transaction=0, skip_encode=False): """Initialize a diagnostic response.""" - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.message = None def encode(self): @@ -150,7 +150,7 @@ class DiagnosticStatusSimpleRequest(DiagnosticStatusRequest): the execute method """ - def __init__(self, data=0x0000, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, data=0x0000, slave=1, transaction=0, skip_encode=False): """Initialize a simple diagnostic request. The data defaults to 0x0000 if not provided as over half @@ -158,7 +158,7 @@ def __init__(self, data=0x0000, slave=1, transaction=0, protocol=0, skip_encode= :param data: The data to send along with the request """ - DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) self.message = data async def execute(self, *args): @@ -175,12 +175,12 @@ class DiagnosticStatusSimpleResponse(DiagnosticStatusResponse): 2 bytes of data. """ - def __init__(self, data=0x0000, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, data=0x0000, slave=1, transaction=0, skip_encode=False): """Return a simple diagnostic response. :param data: The resulting data to return to the client """ - DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) self.message = data @@ -197,12 +197,12 @@ class ReturnQueryDataRequest(DiagnosticStatusRequest): sub_function_code = 0x0000 - def __init__(self, message=b"\x00\x00", slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, message=b"\x00\x00", slave=1, transaction=0, skip_encode=False): """Initialize a new instance of the request. :param message: The message to send to loopback """ - DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) if not isinstance(message, bytes): raise ModbusException(f"message({type(message)}) must be bytes") self.message = message @@ -225,12 +225,12 @@ class ReturnQueryDataResponse(DiagnosticStatusResponse): sub_function_code = 0x0000 - def __init__(self, message=b"\x00\x00", slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, message=b"\x00\x00", slave=1, transaction=0, skip_encode=False): """Initialize a new instance of the response. :param message: The message to loopback """ - DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) if not isinstance(message, bytes): raise ModbusException(f"message({type(message)}) must be bytes") self.message = message @@ -252,12 +252,12 @@ class RestartCommunicationsOptionRequest(DiagnosticStatusRequest): sub_function_code = 0x0001 - def __init__(self, toggle=False, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, toggle=False, slave=1, transaction=0, skip_encode=False): """Initialize a new request. :param toggle: Set to True to toggle, False otherwise """ - DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + DiagnosticStatusRequest.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) if toggle: self.message = [ModbusStatus.ON] else: @@ -285,12 +285,12 @@ class RestartCommunicationsOptionResponse(DiagnosticStatusResponse): sub_function_code = 0x0001 - def __init__(self, toggle=False, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, toggle=False, slave=1, transaction=0, skip_encode=False): """Initialize a new response. :param toggle: Set to True if we toggled, False otherwise """ - DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) if toggle: self.message = [ModbusStatus.ON] else: @@ -396,9 +396,9 @@ class ForceListenOnlyModeResponse(DiagnosticStatusResponse): sub_function_code = 0x0004 should_respond = False - def __init__(self, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, slave=1, transaction=0, skip_encode=False): """Initialize to block a return response.""" - DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + DiagnosticStatusResponse.__init__(self, slave=slave, transaction=transaction, skip_encode=skip_encode) self.message = [] @@ -778,9 +778,9 @@ class GetClearModbusPlusRequest(DiagnosticStatusSimpleRequest): sub_function_code = 0x0015 - def __init__(self, data=0, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, data=0, slave=1, transaction=0, skip_encode=False): """Initialize.""" - super().__init__(slave=slave, transaction=transaction, protocol=protocol, skip_encode=skip_encode) + super().__init__(slave=slave, transaction=transaction, skip_encode=skip_encode) self.message=data def get_response_pdu_size(self): diff --git a/pymodbus/pdu/file_message.py b/pymodbus/pdu/file_message.py index 954b4e89f..3c3a01b31 100644 --- a/pymodbus/pdu/file_message.py +++ b/pymodbus/pdu/file_message.py @@ -89,12 +89,12 @@ class ReadFileRecordRequest(ModbusRequest): function_code_name = "read_file_record" _rtu_byte_count_pos = 2 - def __init__(self, records=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, records=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param records: The file record requests to be read """ - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.records = records or [] def encode(self): @@ -154,12 +154,12 @@ class ReadFileRecordResponse(ModbusResponse): function_code = 0x14 _rtu_byte_count_pos = 2 - def __init__(self, records=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, records=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param records: The requested file records """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.records = records or [] def encode(self): @@ -210,12 +210,12 @@ class WriteFileRecordRequest(ModbusRequest): function_code_name = "write_file_record" _rtu_byte_count_pos = 2 - def __init__(self, records=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, records=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param records: The file record requests to be read """ - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.records = records or [] def encode(self): @@ -274,12 +274,12 @@ class WriteFileRecordResponse(ModbusResponse): function_code = 0x15 _rtu_byte_count_pos = 2 - def __init__(self, records=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, records=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param records: The file record requests to be read """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.records = records or [] def encode(self): @@ -339,12 +339,12 @@ class ReadFifoQueueRequest(ModbusRequest): function_code_name = "read_fifo_queue" _rtu_frame_size = 6 - def __init__(self, address=0x0000, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=0x0000, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The fifo pointer address (0x0000 to 0xffff) """ - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.address = address self.values = [] # this should be added to the context @@ -400,12 +400,12 @@ def calculateRtuFrameSize(cls, buffer): lo_byte = int(buffer[3]) return (hi_byte << 16) + lo_byte + 6 - def __init__(self, values=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, values=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param values: The list of values of the fifo to return """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.values = values or [] def encode(self): diff --git a/pymodbus/pdu/mei_message.py b/pymodbus/pdu/mei_message.py index 71396bbc8..0fc327520 100644 --- a/pymodbus/pdu/mei_message.py +++ b/pymodbus/pdu/mei_message.py @@ -52,13 +52,13 @@ class ReadDeviceInformationRequest(ModbusRequest): function_code_name = "read_device_information" _rtu_frame_size = 7 - def __init__(self, read_code=None, object_id=0x00, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, read_code=None, object_id=0x00, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param read_code: The device information read code :param object_id: The object to read from """ - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) self.read_code = read_code or DeviceInformation.BASIC self.object_id = object_id @@ -130,13 +130,13 @@ def calculateRtuFrameSize(cls, buffer): except struct.error as exc: raise IndexError from exc - def __init__(self, read_code=None, information=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, read_code=None, information=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param read_code: The device information read code :param information: The requested information request """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.read_code = read_code or DeviceInformation.BASIC self.information = information or {} self.number_of_objects = 0 diff --git a/pymodbus/pdu/other_message.py b/pymodbus/pdu/other_message.py index 408fb0042..18423c8a4 100644 --- a/pymodbus/pdu/other_message.py +++ b/pymodbus/pdu/other_message.py @@ -30,9 +30,9 @@ class ReadExceptionStatusRequest(ModbusRequest): function_code_name = "read_exception_status" _rtu_frame_size = 4 - def __init__(self, slave=None, transaction=0, protocol=0, skip_encode=0): + def __init__(self, slave=None, transaction=0, skip_encode=0): """Initialize a new instance.""" - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) def encode(self): """Encode the message.""" @@ -72,12 +72,12 @@ class ReadExceptionStatusResponse(ModbusResponse): function_code = 0x07 _rtu_frame_size = 5 - def __init__(self, status=0x00, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, status=0x00, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param status: The status response to report """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.status = status if status < 256 else 255 def encode(self): @@ -135,9 +135,9 @@ class GetCommEventCounterRequest(ModbusRequest): function_code_name = "get_event_counter" _rtu_frame_size = 4 - def __init__(self, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, slave=1, transaction=0, skip_encode=False): """Initialize a new instance.""" - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) def encode(self): """Encode the message.""" @@ -178,12 +178,12 @@ class GetCommEventCounterResponse(ModbusResponse): function_code = 0x0B _rtu_frame_size = 8 - def __init__(self, count=0x0000, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, count=0x0000, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param count: The current event counter value """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.count = count self.status = True # this means we are ready, not waiting @@ -246,9 +246,9 @@ class GetCommEventLogRequest(ModbusRequest): function_code_name = "get_event_log" _rtu_frame_size = 4 - def __init__(self, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, slave=1, transaction=0, skip_encode=False): """Initialize a new instance.""" - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) def encode(self): """Encode the message.""" @@ -293,7 +293,7 @@ class GetCommEventLogResponse(ModbusResponse): function_code = 0x0C _rtu_byte_count_pos = 2 - def __init__(self, status=True, message_count=0, event_count=0, events=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, status=True, message_count=0, event_count=0, events=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param status: The status response to report @@ -301,7 +301,7 @@ def __init__(self, status=True, message_count=0, event_count=0, events=None, sla :param event_count: The current event count :param events: The collection of events to send """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.status = status self.message_count = message_count self.event_count = event_count @@ -367,13 +367,13 @@ class ReportSlaveIdRequest(ModbusRequest): function_code_name = "report_slave_id" _rtu_frame_size = 4 - def __init__(self, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param slave: Modbus slave slave ID """ - ModbusRequest.__init__(self, slave, transaction, protocol, skip_encode) + ModbusRequest.__init__(self, slave, transaction, skip_encode) def encode(self): """Encode the message.""" @@ -426,13 +426,13 @@ class ReportSlaveIdResponse(ModbusResponse): function_code = 0x11 _rtu_byte_count_pos = 2 - def __init__(self, identifier=b"\x00", status=True, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, identifier=b"\x00", status=True, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param identifier: The identifier of the slave :param status: The status response to report """ - ModbusResponse.__init__(self, slave, transaction, protocol, skip_encode) + ModbusResponse.__init__(self, slave, transaction, skip_encode) self.identifier = identifier self.status = status self.byte_count = None diff --git a/pymodbus/pdu/pdu.py b/pymodbus/pdu/pdu.py index 67f486839..8bf5781a7 100644 --- a/pymodbus/pdu/pdu.py +++ b/pymodbus/pdu/pdu.py @@ -20,11 +20,6 @@ class ModbusPDU: This value is used to uniquely identify a request response pair. It can be implemented as a simple counter - .. attribute:: protocol_id - - This is a constant set at 0 to indicate Modbus. It is - put here for ease of expansion. - .. attribute:: slave_id This is used to route the request to the correct child. In @@ -46,14 +41,13 @@ class ModbusPDU: of encoding it again. """ - def __init__(self, slave, transaction, protocol, skip_encode): + def __init__(self, slave, transaction, skip_encode): """Initialize the base data for a modbus request. :param slave: Modbus slave slave ID """ self.transaction_id = transaction - self.protocol_id = protocol self.slave_id = slave self.skip_encode = skip_encode self.check = 0x0000 @@ -95,12 +89,12 @@ class ModbusRequest(ModbusPDU): function_code = -1 - def __init__(self, slave, transaction, protocol, skip_encode): + def __init__(self, slave, transaction, skip_encode): """Proxy to the lower level initializer. :param slave: Modbus slave slave ID """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.fut = None def doException(self, exception): @@ -131,13 +125,13 @@ class ModbusResponse(ModbusPDU): should_respond = True function_code = 0x00 - def __init__(self, slave, transaction, protocol, skip_encode): + def __init__(self, slave, transaction, skip_encode): """Proxy the lower level initializer. :param slave: Modbus slave slave ID """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.bits = [] self.registers = [] self.request = None @@ -184,13 +178,13 @@ class ExceptionResponse(ModbusResponse): ExceptionOffset = 0x80 _rtu_frame_size = 5 - def __init__(self, function_code, exception_code=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, function_code, exception_code=None, slave=1, transaction=0, skip_encode=False): """Initialize the modbus exception response. :param function_code: The function to build an exception response for :param exception_code: The specific modbus exception to return """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.original_code = function_code self.function_code = function_code | self.ExceptionOffset self.exception_code = exception_code @@ -233,12 +227,12 @@ class IllegalFunctionRequest(ModbusRequest): ErrorCode = 1 - def __init__(self, function_code, xslave, xtransaction, xprotocol, xskip_encode): + def __init__(self, function_code, slave, transaction, xskip_encode): """Initialize a IllegalFunctionRequest. :param function_code: The function we are erroring on """ - super().__init__(xslave, xtransaction, xprotocol, xskip_encode) + super().__init__(slave, transaction, xskip_encode) self.function_code = function_code def decode(self, _data): diff --git a/pymodbus/pdu/register_read_message.py b/pymodbus/pdu/register_read_message.py index 7d05e6ed2..f8a3c465b 100644 --- a/pymodbus/pdu/register_read_message.py +++ b/pymodbus/pdu/register_read_message.py @@ -14,14 +14,14 @@ class ReadRegistersRequestBase(ModbusRequest): _rtu_frame_size = 8 - def __init__(self, address, count, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address, count, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The address to start the read from :param count: The number of registers to read :param slave: Modbus slave slave ID """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.address = address self.count = count @@ -62,13 +62,13 @@ class ReadRegistersResponseBase(ModbusResponse): _rtu_byte_count_pos = 2 - def __init__(self, values, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, values, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param values: The values to write to :param slave: Modbus slave slave ID """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) #: A list of register values self.registers = values or [] @@ -124,14 +124,14 @@ class ReadHoldingRegistersRequest(ReadRegistersRequestBase): function_code = 3 function_code_name = "read_holding_registers" - def __init__(self, address=None, count=None, slave=1, transaction=0, protocol=0, skip_encode=0): + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=0): """Initialize a new instance of the request. :param address: The starting address to read from :param count: The number of registers to read from address :param slave: Modbus slave slave ID """ - super().__init__(address, count, slave, transaction, protocol, skip_encode) + super().__init__(address, count, slave, transaction, skip_encode) async def execute(self, context): """Run a read holding request against a datastore. @@ -165,12 +165,12 @@ class ReadHoldingRegistersResponse(ReadRegistersResponseBase): function_code = 3 - def __init__(self, values=None, slave=None, transaction=0, protocol=0, skip_encode=0): + def __init__(self, values=None, slave=None, transaction=0, skip_encode=0): """Initialize a new response instance. :param values: The resulting register values """ - super().__init__(values, slave, transaction, protocol, skip_encode) + super().__init__(values, slave, transaction, skip_encode) class ReadInputRegistersRequest(ReadRegistersRequestBase): @@ -186,14 +186,14 @@ class ReadInputRegistersRequest(ReadRegistersRequestBase): function_code = 4 function_code_name = "read_input_registers" - def __init__(self, address=None, count=None, slave=1, transaction=0, protocol=0, skip_encode=0): + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=0): """Initialize a new instance of the request. :param address: The starting address to read from :param count: The number of registers to read from address :param slave: Modbus slave slave ID """ - super().__init__(address, count, slave, transaction, protocol, skip_encode) + super().__init__(address, count, slave, transaction, skip_encode) async def execute(self, context): """Run a read input request against a datastore. @@ -227,12 +227,12 @@ class ReadInputRegistersResponse(ReadRegistersResponseBase): function_code = 4 - def __init__(self, values=None, slave=None, transaction=0, protocol=0, skip_encode=0): + def __init__(self, values=None, slave=None, transaction=0, skip_encode=0): """Initialize a new response instance. :param values: The resulting register values """ - super().__init__(values, slave, transaction, protocol, skip_encode) + super().__init__(values, slave, transaction, skip_encode) class ReadWriteMultipleRegistersRequest(ModbusRequest): @@ -255,7 +255,7 @@ class ReadWriteMultipleRegistersRequest(ModbusRequest): function_code_name = "read_write_multiple_registers" _rtu_byte_count_pos = 10 - def __init__(self, read_address=0x00, read_count=0, write_address=0x00, write_registers=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, read_address=0x00, read_count=0, write_address=0x00, write_registers=None, slave=1, transaction=0, skip_encode=False): """Initialize a new request message. :param read_address: The address to start reading from @@ -263,7 +263,7 @@ def __init__(self, read_address=0x00, read_count=0, write_address=0x00, write_re :param write_address: The address to start writing to :param write_registers: The registers to write to the specified address """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.read_address = read_address self.read_count = read_count self.write_address = write_address diff --git a/pymodbus/pdu/register_write_message.py b/pymodbus/pdu/register_write_message.py index 5ee9a1c40..6a1abb3aa 100644 --- a/pymodbus/pdu/register_write_message.py +++ b/pymodbus/pdu/register_write_message.py @@ -20,13 +20,13 @@ class WriteSingleRegisterRequest(ModbusRequest): function_code_name = "write_register" _rtu_frame_size = 8 - def __init__(self, address=None, value=None, slave=None, transaction=0, protocol=0, skip_encode=0): + def __init__(self, address=None, value=None, slave=None, transaction=0, skip_encode=0): """Initialize a new instance. :param address: The address to start writing add :param value: The values to write """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.address = address self.value = value @@ -91,13 +91,13 @@ class WriteSingleRegisterResponse(ModbusResponse): function_code = 6 _rtu_frame_size = 8 - def __init__(self, address=None, value=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=None, value=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The address to start writing add :param value: The values to write """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.address = address self.value = value @@ -152,13 +152,13 @@ class WriteMultipleRegistersRequest(ModbusRequest): _rtu_byte_count_pos = 6 _pdu_length = 5 # func + adress1 + adress2 + outputQuant1 + outputQuant2 - def __init__(self, address=None, values=None, slave=None, transaction=0, protocol=0, skip_encode=0): + def __init__(self, address=None, values=None, slave=None, transaction=0, skip_encode=0): """Initialize a new instance. :param address: The address to start writing to :param values: The values to write """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.address = address if values is None: values = [] @@ -239,13 +239,13 @@ class WriteMultipleRegistersResponse(ModbusResponse): function_code = 16 _rtu_frame_size = 8 - def __init__(self, address=None, count=None, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=None, count=None, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The address to start writing to :param count: The number of registers to write to """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.address = address self.count = count @@ -287,14 +287,14 @@ class MaskWriteRegisterRequest(ModbusRequest): function_code_name = "mask_write_register" _rtu_frame_size = 10 - def __init__(self, address=0x0000, and_mask=0xFFFF, or_mask=0x0000, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=0x0000, and_mask=0xFFFF, or_mask=0x0000, slave=1, transaction=0, skip_encode=False): """Initialize a new instance. :param address: The mask pointer address (0x0000 to 0xffff) :param and_mask: The and bitmask to apply to the register address :param or_mask: The or bitmask to apply to the register address """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.address = address self.and_mask = and_mask self.or_mask = or_mask @@ -342,14 +342,14 @@ class MaskWriteRegisterResponse(ModbusResponse): function_code = 0x16 _rtu_frame_size = 10 - def __init__(self, address=0x0000, and_mask=0xFFFF, or_mask=0x0000, slave=1, transaction=0, protocol=0, skip_encode=False): + def __init__(self, address=0x0000, and_mask=0xFFFF, or_mask=0x0000, slave=1, transaction=0, skip_encode=False): """Initialize new instance. :param address: The mask pointer address (0x0000 to 0xffff) :param and_mask: The and bitmask applied to the register address :param or_mask: The or bitmask applied to the register address """ - super().__init__(slave, transaction, protocol, skip_encode) + super().__init__(slave, transaction, skip_encode) self.address = address self.and_mask = and_mask self.or_mask = or_mask diff --git a/test/framers/test_tbc_transaction.py b/test/framers/test_tbc_transaction.py index 411eb3784..66b74d592 100755 --- a/test/framers/test_tbc_transaction.py +++ b/test/framers/test_tbc_transaction.py @@ -315,9 +315,8 @@ def callback(data): count += 1 result = data - expected = ModbusRequest(0, 0, 0, False) + expected = ModbusRequest(0, 0, False) expected.transaction_id = 0x0001 - expected.protocol_id = 0x1234 expected.slave_id = 0xFF msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" self._tcp.processIncomingPacket(msg, callback, [0, 1]) @@ -331,9 +330,8 @@ def test_tcp_framer_packet(self): """Test a tcp frame packet build.""" old_encode = ModbusRequest.encode ModbusRequest.encode = lambda self: b"" - message = ModbusRequest(0, 0, 0, False) + message = ModbusRequest(0, 0, False) message.transaction_id = 0x0001 - message.protocol_id = 0x0000 message.slave_id = 0xFF message.function_code = 0x01 expected = b"\x00\x01\x00\x00\x00\x02\xff\x01" @@ -486,7 +484,7 @@ def test_framer_tls_framer_packet(self): """Test a tls frame packet build.""" old_encode = ModbusRequest.encode ModbusRequest.encode = lambda self: b"" - message = ModbusRequest(0, 0, 0, False) + message = ModbusRequest(0, 0, False) message.function_code = 0x01 expected = b"\x01" actual = self._tls.buildPacket(message) @@ -560,7 +558,7 @@ def test_rtu_framer_packet(self): """Test a rtu frame packet build.""" old_encode = ModbusRequest.encode ModbusRequest.encode = lambda self: b"" - message = ModbusRequest(0, 0, 0, False) + message = ModbusRequest(0, 0, False) message.slave_id = 0xFF message.function_code = 0x01 expected = b"\xff\x01\x81\x80" # only header + CRC - no data @@ -661,7 +659,7 @@ def callback(data): def test_ascii_framer_populate(self): """Test a ascii frame packet build.""" - request = ModbusRequest(0, 0, 0, False) + request = ModbusRequest(0, 0, False) self._ascii.populateResult(request) assert not request.slave_id @@ -669,7 +667,7 @@ def test_ascii_framer_packet(self): """Test a ascii frame packet build.""" old_encode = ModbusRequest.encode ModbusRequest.encode = lambda self: b"" - message = ModbusRequest(0, 0, 0, False) + message = ModbusRequest(0, 0, False) message.slave_id = 0xFF message.function_code = 0x01 expected = b":FF0100\r\n" diff --git a/test/sub_client/test_client.py b/test/sub_client/test_client.py index b59d9149b..d82ce2c5b 100755 --- a/test/sub_client/test_client.py +++ b/test/sub_client/test_client.py @@ -243,7 +243,7 @@ async def test_client_instanciate( client.connect = lambda: False client.transport = None with pytest.raises(ConnectionException): - client.execute(ModbusRequest(0, 0, 0, False)) + client.execute(ModbusRequest(0, 0, False)) async def test_client_modbusbaseclient(): """Test modbus base client class.""" @@ -679,13 +679,13 @@ async def test_client_build_response(): comm_params=CommParams(), ) with pytest.raises(ConnectionException): - await client.build_response(ModbusRequest(0, 0, 0, False)) + await client.build_response(ModbusRequest(0, 0, False)) async def test_client_mixin_execute(): """Test dummy execute for both sync and async.""" client = ModbusClientMixin() with pytest.raises(NotImplementedError): - client.execute(ModbusRequest(0, 0, 0, False)) + client.execute(ModbusRequest(0, 0, False)) with pytest.raises(NotImplementedError): - await client.execute(ModbusRequest(0, 0, 0, False)) + await client.execute(ModbusRequest(0, 0, False)) diff --git a/test/sub_current/test_pdu.py b/test/sub_current/test_pdu.py index aa18b2d6c..5c17b4088 100644 --- a/test/sub_current/test_pdu.py +++ b/test/sub_current/test_pdu.py @@ -15,11 +15,11 @@ class TestPdu: """Unittest for the pymod.pdu module.""" bad_requests = ( - ModbusRequest(0, 0, 0, False), - ModbusResponse(0, 0, 0, False), + ModbusRequest(0, 0, False), + ModbusResponse(0, 0, False), ) - illegal = IllegalFunctionRequest(1, 0, 0, 0, False) - exception = ExceptionResponse(1, 1, 0, 0, 0, False) + illegal = IllegalFunctionRequest(1, 0, 0, False) + exception = ExceptionResponse(1, 1, 0, 0, False) def test_not_impelmented(self): """Test a base classes for not implemented functions.""" @@ -43,7 +43,7 @@ async def test_error_methods(self): def test_request_exception_factory(self): """Test all error methods.""" - request = ModbusRequest(0, 0, 0, False) + request = ModbusRequest(0, 0, False) request.function_code = 1 errors = {ModbusExceptions.decode(c): c for c in range(1, 20)} for error, code in iter(errors.items()): diff --git a/test/sub_current/test_transaction.py b/test/sub_current/test_transaction.py index 741002d80..e7cb2b821 100755 --- a/test/sub_current/test_transaction.py +++ b/test/sub_current/test_transaction.py @@ -172,7 +172,7 @@ def test_get_transaction_manager_transaction(self): """Test the getting a transaction from the transaction manager.""" self._manager.reset() handle = ModbusRequest( - 0, self._manager.getNextTID(), 0, False + 0, self._manager.getNextTID(), False ) self._manager.addTransaction(handle) result = self._manager.getTransaction(handle.transaction_id) @@ -182,7 +182,7 @@ def test_delete_transaction_manager_transaction(self): """Test deleting a transaction from the dict transaction manager.""" self._manager.reset() handle = ModbusRequest( - 0, self._manager.getNextTID(), 0, False + 0, self._manager.getNextTID(), False ) self._manager.addTransaction(handle) self._manager.delTransaction(handle.transaction_id) @@ -303,9 +303,8 @@ def callback(data): count += 1 result = data - expected = ModbusRequest(0, 0, 0, False) + expected = ModbusRequest(0, 0, False) expected.transaction_id = 0x0001 - expected.protocol_id = 0x1234 expected.slave_id = 0xFF msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" self._tcp.processIncomingPacket(msg, callback, [0, 1]) @@ -318,9 +317,8 @@ def callback(data): @mock.patch.object(ModbusRequest, "encode") def test_tcp_framer_packet(self, mock_encode): """Test a tcp frame packet build.""" - message = ModbusRequest(0, 0, 0, False) + message = ModbusRequest(0, 0, False) message.transaction_id = 0x0001 - message.protocol_id = 0x0000 message.slave_id = 0xFF message.function_code = 0x01 expected = b"\x00\x01\x00\x00\x00\x02\xff\x01" @@ -472,7 +470,7 @@ def callback(data): @mock.patch.object(ModbusRequest, "encode") def test_framer_tls_framer_packet(self, mock_encode): """Test a tls frame packet build.""" - message = ModbusRequest(0, 0, 0, False) + message = ModbusRequest(0, 0, False) message.function_code = 0x01 expected = b"\x01" mock_encode.return_value = b"" @@ -545,7 +543,7 @@ def callback(data): @mock.patch.object(ModbusRequest, "encode") def test_rtu_framer_packet(self, mock_encode): """Test a rtu frame packet build.""" - message = ModbusRequest(0, 0, 0, False) + message = ModbusRequest(0, 0, False) message.slave_id = 0xFF message.function_code = 0x01 expected = b"\xff\x01\x81\x80" # only header + CRC - no data @@ -646,14 +644,14 @@ def callback(data): def test_ascii_framer_populate(self): """Test a ascii frame packet build.""" - request = ModbusRequest(0, 0, 0, False) + request = ModbusRequest(0, 0, False) self._ascii.populateResult(request) assert not request.slave_id @mock.patch.object(ModbusRequest, "encode") def test_ascii_framer_packet(self, mock_encode): """Test a ascii frame packet build.""" - message = ModbusRequest(0, 0, 0, False) + message = ModbusRequest(0, 0, False) message.slave_id = 0xFF message.function_code = 0x01 expected = b":FF0100\r\n" diff --git a/test/sub_function_codes/test_all_messages.py b/test/sub_function_codes/test_all_messages.py index 3454cf191..e2581e9d4 100644 --- a/test/sub_function_codes/test_all_messages.py +++ b/test/sub_function_codes/test_all_messages.py @@ -84,12 +84,10 @@ def test_initializing_slave_address_response(self): def test_forwarding_to_pdu(self): """Test that parameters are forwarded to the pdu correctly.""" - request = ReadCoilsRequest(1, 5, slave=18, transaction=0x12, protocol=0x12) + request = ReadCoilsRequest(1, 5, slave=18, transaction=0x12,) assert request.slave_id == 0x12 assert request.transaction_id == 0x12 - assert request.protocol_id == 0x12 request = ReadCoilsRequest(1, 5) assert request.slave_id == 1 assert not request.transaction_id - assert not request.protocol_id diff --git a/test/sub_function_codes/test_bit_read_messages.py b/test/sub_function_codes/test_bit_read_messages.py index 99c095560..85f807e37 100644 --- a/test/sub_function_codes/test_bit_read_messages.py +++ b/test/sub_function_codes/test_bit_read_messages.py @@ -40,17 +40,17 @@ def tearDown(self): def test_read_bit_base_class_methods(self): """Test basic bit message encoding/decoding.""" - handle = ReadBitsRequestBase(1, 1, 0, 0, 0, False) + handle = ReadBitsRequestBase(1, 1, 0, 0, False) msg = "ReadBitRequest(1,1)" assert msg == str(handle) - handle = ReadBitsResponseBase([1, 1], 0, 0, 0, False) + handle = ReadBitsResponseBase([1, 1], 0, 0, False) msg = "ReadBitsResponseBase(2)" assert msg == str(handle) def test_bit_read_base_request_encoding(self): """Test basic bit message encoding/decoding.""" for i in range(20): - handle = ReadBitsRequestBase(i, i, 0, 0, 0, False) + handle = ReadBitsRequestBase(i, i, 0, 0, False) result = struct.pack(">HH", i, i) assert handle.encode() == result handle.decode(result) @@ -60,7 +60,7 @@ def test_bit_read_base_response_encoding(self): """Test basic bit message encoding/decoding.""" for i in range(20): data = [True] * i - handle = ReadBitsResponseBase(data, 0, 0, 0, False) + handle = ReadBitsResponseBase(data, 0, 0, False) result = handle.encode() handle.decode(result) assert handle.bits[:i] == data @@ -68,7 +68,7 @@ def test_bit_read_base_response_encoding(self): def test_bit_read_base_response_helper_methods(self): """Test the extra methods on a ReadBitsResponseBase.""" data = [False] * 8 - handle = ReadBitsResponseBase(data, 0, 0, 0, False) + handle = ReadBitsResponseBase(data, 0, 0, False) for i in (1, 3, 5): handle.setBit(i, True) for i in (1, 3, 5): @@ -79,8 +79,8 @@ def test_bit_read_base_response_helper_methods(self): def test_bit_read_base_requests(self): """Test bit read request encoding.""" messages = { - ReadBitsRequestBase(12, 14, 0, 0, 0, False): b"\x00\x0c\x00\x0e", - ReadBitsResponseBase([1, 0, 1, 1, 0], 0, 0, 0, False): b"\x01\x0d", + ReadBitsRequestBase(12, 14, 0, 0, False): b"\x00\x0c\x00\x0e", + ReadBitsResponseBase([1, 0, 1, 1, 0], 0, 0, False): b"\x01\x0d", } for request, expected in iter(messages.items()): assert request.encode() == expected @@ -89,8 +89,8 @@ async def test_bit_read_message_execute_value_errors(self): """Test bit read request encoding.""" context = MockContext() requests = [ - ReadCoilsRequest(1, 0x800, 0, 0, 0, False), - ReadDiscreteInputsRequest(1, 0x800, 0, 0, 0, False), + ReadCoilsRequest(1, 0x800, 0, 0, False), + ReadDiscreteInputsRequest(1, 0x800, 0, 0, False), ] for request in requests: result = await request.execute(context) @@ -100,8 +100,8 @@ async def test_bit_read_message_execute_address_errors(self): """Test bit read request encoding.""" context = MockContext() requests = [ - ReadCoilsRequest(1, 5, 0, 0, 0, False), - ReadDiscreteInputsRequest(1, 5, 0, 0, 0, False), + ReadCoilsRequest(1, 5, 0, 0, False), + ReadDiscreteInputsRequest(1, 5, 0, 0, False), ] for request in requests: result = await request.execute(context) @@ -112,8 +112,8 @@ async def test_bit_read_message_execute_success(self): context = MockContext() context.validate = lambda a, b, c: True requests = [ - ReadCoilsRequest(1, 5, 0, 0, 0, False), - ReadDiscreteInputsRequest(1, 5, 0, 0, 0, False), + ReadCoilsRequest(1, 5, 0, 0, False), + ReadDiscreteInputsRequest(1, 5, 0, False), ] for request in requests: result = await request.execute(context) @@ -122,12 +122,12 @@ async def test_bit_read_message_execute_success(self): def test_bit_read_message_get_response_pdu(self): """Test bit read message get response pdu.""" requests = { - ReadCoilsRequest(1, 5, 0, 0, 0, False): 3, - ReadCoilsRequest(1, 8, 0, 0, 0, False): 3, - ReadCoilsRequest(0, 16, 0, 0, 0, False): 4, - ReadDiscreteInputsRequest(1, 21, 0, 0, 0, False): 5, - ReadDiscreteInputsRequest(1, 24, 0, 0, 0, False): 5, - ReadDiscreteInputsRequest(1, 1900, 0, 0, 0, False): 240, + ReadCoilsRequest(1, 5, 0, 0, False): 3, + ReadCoilsRequest(1, 8, 0, 0, False): 3, + ReadCoilsRequest(0, 16, 0, 0, False): 4, + ReadDiscreteInputsRequest(1, 21, 0, 0, False): 5, + ReadDiscreteInputsRequest(1, 24, 0, 0, False): 5, + ReadDiscreteInputsRequest(1, 1900, 0, 0, False): 240, } for request, expected in iter(requests.items()): pdu_len = request.get_response_pdu_size() From a4cf640f9cfd0ae2cba0c9dc3b51801c31c511c9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 28 Sep 2024 21:23:27 +0200 Subject: [PATCH 24/41] Reset receive buffer with send(). (#2343) --- pymodbus/transport/transport.py | 1 + test/transport/test_comm.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pymodbus/transport/transport.py b/pymodbus/transport/transport.py index 34bd86a7b..7fa1a92d3 100644 --- a/pymodbus/transport/transport.py +++ b/pymodbus/transport/transport.py @@ -377,6 +377,7 @@ def send(self, data: bytes, addr: tuple | None = None) -> None: Log.error("Cancel send, because not connected!") return Log.debug("send: {}", data, ":hex") + self.recv_buffer = b"" if self.comm_params.handle_local_echo: self.sent_buffer += data if self.comm_params.comm_type == CommType.UDP: diff --git a/test/transport/test_comm.py b/test/transport/test_comm.py index 141b4c8d3..2639b3b99 100644 --- a/test/transport/test_comm.py +++ b/test/transport/test_comm.py @@ -242,7 +242,7 @@ async def test_connected_multiple(self, client, server, use_port): client2.send(test_data) await asyncio.sleep(0.5) - assert server2_connected.recv_buffer == test2_data + test_data + assert server2_connected.recv_buffer == test_data client2.close() server.close() await asyncio.sleep(0.5) From 88441a805fe8e3c9043e382f98085b563cbb02ba Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 30 Sep 2024 12:44:10 +0200 Subject: [PATCH 25/41] Remove reconnect param from API close. --- pymodbus/client/base.py | 9 +++------ pymodbus/client/serial.py | 4 ++-- pymodbus/client/tcp.py | 6 ++---- pymodbus/client/tls.py | 4 ++++ pymodbus/client/udp.py | 4 ++++ 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 26d7871b8..1c9bba9e9 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -76,12 +76,9 @@ def register(self, custom_response_class: ModbusResponse) -> None: """ self.ctx.framer.decoder.register(custom_response_class) - def close(self, reconnect: bool = False) -> None: + def close(self) -> None: """Close connection.""" - if reconnect: - self.ctx.connection_lost(asyncio.TimeoutError("Server not responding")) - else: - self.ctx.close() + self.ctx.close() def idle_time(self) -> float: """Time before initiating next transaction (call **sync**). @@ -126,7 +123,7 @@ async def async_execute(self, request) -> ModbusResponse: except asyncio.exceptions.TimeoutError: count += 1 if count > self.retries: - self.close(reconnect=True) + self.ctx.connection_lost(asyncio.TimeoutError("Server not responding")) raise ModbusIOException( f"ERROR: No response received after {self.retries} retries" ) diff --git a/pymodbus/client/serial.py b/pymodbus/client/serial.py index 2da85a2ba..e4bd4e585 100644 --- a/pymodbus/client/serial.py +++ b/pymodbus/client/serial.py @@ -102,9 +102,9 @@ def __init__( # pylint: disable=too-many-arguments on_connect_callback, ) - def close(self, reconnect: bool = False) -> None: + def close(self) -> None: """Close connection.""" - super().close(reconnect=reconnect) + super().close() class ModbusSerialClient(ModbusBaseSyncClient): diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py index 0a431a5d2..6684fd75a 100644 --- a/pymodbus/client/tcp.py +++ b/pymodbus/client/tcp.py @@ -85,9 +85,9 @@ def __init__( # pylint: disable=too-many-arguments on_connect_callback, ) - def close(self, reconnect: bool = False) -> None: + def close(self) -> None: """Close connection.""" - super().close(reconnect=reconnect) + super().close() class ModbusTcpClient(ModbusBaseSyncClient): @@ -128,8 +128,6 @@ async def run(): Please refer to :ref:`Pymodbus internals` for advanced usage. """ - socket: socket.socket | None - def __init__( self, host: str, diff --git a/pymodbus/client/tls.py b/pymodbus/client/tls.py index 55a224fb1..f2440733b 100644 --- a/pymodbus/client/tls.py +++ b/pymodbus/client/tls.py @@ -223,6 +223,10 @@ def connect(self): self.close() return self.socket is not None + def close(self) -> None: + """Close connection.""" + super().close() + def __repr__(self): """Return string representation.""" return ( diff --git a/pymodbus/client/udp.py b/pymodbus/client/udp.py index 23c6db759..7f9c52b62 100644 --- a/pymodbus/client/udp.py +++ b/pymodbus/client/udp.py @@ -89,6 +89,10 @@ def connected(self): """Return true if connected.""" return self.ctx.is_active() + def close(self) -> None: + """Close connection.""" + super().close() + class ModbusUdpClient(ModbusBaseSyncClient): """**ModbusUdpClient**. From 7abd1d7ad39d4037a239529d927cb233ff2b4e52 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 30 Sep 2024 12:47:15 +0200 Subject: [PATCH 26/41] Revert "Remove reconnect param from API close." This reverts commit 88441a805fe8e3c9043e382f98085b563cbb02ba. --- pymodbus/client/base.py | 9 ++++++--- pymodbus/client/serial.py | 4 ++-- pymodbus/client/tcp.py | 6 ++++-- pymodbus/client/tls.py | 4 ---- pymodbus/client/udp.py | 4 ---- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 1c9bba9e9..26d7871b8 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -76,9 +76,12 @@ def register(self, custom_response_class: ModbusResponse) -> None: """ self.ctx.framer.decoder.register(custom_response_class) - def close(self) -> None: + def close(self, reconnect: bool = False) -> None: """Close connection.""" - self.ctx.close() + if reconnect: + self.ctx.connection_lost(asyncio.TimeoutError("Server not responding")) + else: + self.ctx.close() def idle_time(self) -> float: """Time before initiating next transaction (call **sync**). @@ -123,7 +126,7 @@ async def async_execute(self, request) -> ModbusResponse: except asyncio.exceptions.TimeoutError: count += 1 if count > self.retries: - self.ctx.connection_lost(asyncio.TimeoutError("Server not responding")) + self.close(reconnect=True) raise ModbusIOException( f"ERROR: No response received after {self.retries} retries" ) diff --git a/pymodbus/client/serial.py b/pymodbus/client/serial.py index e4bd4e585..2da85a2ba 100644 --- a/pymodbus/client/serial.py +++ b/pymodbus/client/serial.py @@ -102,9 +102,9 @@ def __init__( # pylint: disable=too-many-arguments on_connect_callback, ) - def close(self) -> None: + def close(self, reconnect: bool = False) -> None: """Close connection.""" - super().close() + super().close(reconnect=reconnect) class ModbusSerialClient(ModbusBaseSyncClient): diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py index 6684fd75a..0a431a5d2 100644 --- a/pymodbus/client/tcp.py +++ b/pymodbus/client/tcp.py @@ -85,9 +85,9 @@ def __init__( # pylint: disable=too-many-arguments on_connect_callback, ) - def close(self) -> None: + def close(self, reconnect: bool = False) -> None: """Close connection.""" - super().close() + super().close(reconnect=reconnect) class ModbusTcpClient(ModbusBaseSyncClient): @@ -128,6 +128,8 @@ async def run(): Please refer to :ref:`Pymodbus internals` for advanced usage. """ + socket: socket.socket | None + def __init__( self, host: str, diff --git a/pymodbus/client/tls.py b/pymodbus/client/tls.py index f2440733b..55a224fb1 100644 --- a/pymodbus/client/tls.py +++ b/pymodbus/client/tls.py @@ -223,10 +223,6 @@ def connect(self): self.close() return self.socket is not None - def close(self) -> None: - """Close connection.""" - super().close() - def __repr__(self): """Return string representation.""" return ( diff --git a/pymodbus/client/udp.py b/pymodbus/client/udp.py index 7f9c52b62..23c6db759 100644 --- a/pymodbus/client/udp.py +++ b/pymodbus/client/udp.py @@ -89,10 +89,6 @@ def connected(self): """Return true if connected.""" return self.ctx.is_active() - def close(self) -> None: - """Close connection.""" - super().close() - class ModbusUdpClient(ModbusBaseSyncClient): """**ModbusUdpClient**. From fe3c775019f28ae22bf4ac4cae8d662dfcd27f93 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 30 Sep 2024 13:27:03 +0200 Subject: [PATCH 27/41] Client doc, add common methods (base). (#2348) --- doc/source/_static/examples.tgz | Bin 41841 -> 45644 bytes doc/source/_static/examples.zip | Bin 38438 -> 38378 bytes doc/source/client.rst | 18 ++++++++++++++++-- pymodbus/client/base.py | 31 +++++++++++++++++++++++++------ 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/doc/source/_static/examples.tgz b/doc/source/_static/examples.tgz index fe1e85615c6ad8348d6e4ed6689852e5e4189b95..15011cffdb7a5fc1f4b8c5a7f4e22fb3eb41d899 100644 GIT binary patch literal 45644 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 I&j6kS0MieKssI20 literal 41841 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 Yb3)bd0LYK$JDA3^WIk%cnBA-PKLCKHumAu6 diff --git a/doc/source/client.rst b/doc/source/client.rst index ec71e15b9..ae4a8404e 100644 --- a/doc/source/client.rst +++ b/doc/source/client.rst @@ -55,9 +55,9 @@ Pymodbus offers clients with transport different protocols and different framers - ASCII - RTU - RTU_OVER_TCP - - Socket + - SOCKET - TLS - * - Serial (RS-485) + * - SERIAL (RS-485) - Yes - Yes - No @@ -248,6 +248,20 @@ There are a client class for each type of communication and for asynchronous/syn - :mod:`AsyncModbusUdpClient` - :mod:`ModbusUdpClient` +Client common +^^^^^^^^^^^^^ +Some methods are common to all client: + +.. autoclass:: pymodbus.client.base.ModbusBaseClient + :members: + :member-order: bysource + :show-inheritance: + +.. autoclass:: pymodbus.client.base.ModbusBaseSyncClient + :members: + :member-order: bysource + :show-inheritance: + Client serial ^^^^^^^^^^^^^ .. autoclass:: pymodbus.client.AsyncModbusSerialClient diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 26d7871b8..9123381fa 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -32,7 +32,10 @@ def __init__( on_connect_callback: Callable[[bool], None] | None, comm_params: CommParams | None = None, ) -> None: - """Initialize a client instance.""" + """Initialize a client instance. + + :meta private: + """ ModbusClientMixin.__init__(self) # type: ignore[arg-type] if comm_params: self.comm_params = comm_params @@ -99,13 +102,18 @@ def execute(self, request: ModbusRequest): :param request: The request to process :returns: The result of the request execution :raises ConnectionException: Check exception text. + + :meta private: """ if not self.ctx.transport: raise ConnectionException(f"Not connected[{self!s}]") return self.async_execute(request) async def async_execute(self, request) -> ModbusResponse: - """Execute requests asynchronously.""" + """Execute requests asynchronously. + + :meta private: + """ request.transaction_id = self.ctx.transaction.getNextTID() packet = self.ctx.framer.buildPacket(request) @@ -134,7 +142,10 @@ async def async_execute(self, request) -> ModbusResponse: return resp # type: ignore[return-value] def build_response(self, request: ModbusRequest): - """Return a deferred response for the current request.""" + """Return a deferred response for the current request. + + :meta private: + """ my_future: asyncio.Future = asyncio.Future() request.fut = my_future if not self.ctx.transport: @@ -179,7 +190,10 @@ def __init__( retries: int, comm_params: CommParams | None = None, ) -> None: - """Initialize a client instance.""" + """Initialize a client instance. + + :meta private: + """ ModbusClientMixin.__init__(self) # type: ignore[arg-type] if comm_params: self.comm_params = comm_params @@ -205,7 +219,7 @@ def __init__( # Client external interface # ----------------------------------------------------------------------- # def register(self, custom_response_class: ModbusResponse) -> None: - """Register a custom response class with the decoder (call **sync**). + """Register a custom response class with the decoder. :param custom_response_class: (optional) Modbus response class. :raises MessageRegisterException: Check exception text. @@ -231,6 +245,8 @@ def execute(self, request: ModbusRequest) -> ModbusResponse: :param request: The request to process :returns: The result of the request execution :raises ConnectionException: Check exception text. + + :meta private: """ if not self.connect(): raise ConnectionException(f"Failed to connect[{self!s}]") @@ -264,7 +280,10 @@ def recv(self, size: int | None) -> bytes: @classmethod def get_address_family(cls, address): - """Get the correct address family.""" + """Get the correct address family. + + :meta private: + """ try: _ = socket.inet_pton(socket.AF_INET6, address) except OSError: # not a valid ipv6 address From 797ab4947355cd29a3c8eea032d4b27b032d4b56 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 30 Sep 2024 13:41:01 +0200 Subject: [PATCH 28/41] Remove async client.idle_time(). (#2349) --- pymodbus/client/base.py | 10 ---------- test/sub_client/test_client.py | 2 -- 2 files changed, 12 deletions(-) diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 9123381fa..cf03882d4 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -86,16 +86,6 @@ def close(self, reconnect: bool = False) -> None: else: self.ctx.close() - def idle_time(self) -> float: - """Time before initiating next transaction (call **sync**). - - Applications can call message functions without checking idle_time(), - this is done automatically. - """ - if self.last_frame_end is None or self.silent_interval is None: - return 0 - return self.last_frame_end + self.silent_interval - def execute(self, request: ModbusRequest): """Execute request and get response (call **sync/async**). diff --git a/test/sub_client/test_client.py b/test/sub_client/test_client.py index d82ce2c5b..894cf4a70 100755 --- a/test/sub_client/test_client.py +++ b/test/sub_client/test_client.py @@ -230,9 +230,7 @@ async def test_client_instanciate( # Test information methods client.last_frame_end = 2 client.silent_interval = 2 - assert client.idle_time() == 4 client.last_frame_end = None - assert not client.idle_time() # a successful execute client.connect = lambda: True From b0deefca07f522d35fc48fb9b09e6eae5cc56ea6 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 30 Sep 2024 13:46:01 +0200 Subject: [PATCH 29/41] Client.close should not allow reconnect= (#2347) --- pymodbus/client/base.py | 9 +++------ pymodbus/client/serial.py | 4 ---- pymodbus/client/tcp.py | 6 ------ pymodbus/client/udp.py | 5 ----- 4 files changed, 3 insertions(+), 21 deletions(-) diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index cf03882d4..06bbe0a1f 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -79,12 +79,9 @@ def register(self, custom_response_class: ModbusResponse) -> None: """ self.ctx.framer.decoder.register(custom_response_class) - def close(self, reconnect: bool = False) -> None: + def close(self) -> None: """Close connection.""" - if reconnect: - self.ctx.connection_lost(asyncio.TimeoutError("Server not responding")) - else: - self.ctx.close() + self.ctx.close() def execute(self, request: ModbusRequest): """Execute request and get response (call **sync/async**). @@ -124,7 +121,7 @@ async def async_execute(self, request) -> ModbusResponse: except asyncio.exceptions.TimeoutError: count += 1 if count > self.retries: - self.close(reconnect=True) + self.ctx.connection_lost(asyncio.TimeoutError("Server not responding")) raise ModbusIOException( f"ERROR: No response received after {self.retries} retries" ) diff --git a/pymodbus/client/serial.py b/pymodbus/client/serial.py index 2da85a2ba..9e3814b84 100644 --- a/pymodbus/client/serial.py +++ b/pymodbus/client/serial.py @@ -102,10 +102,6 @@ def __init__( # pylint: disable=too-many-arguments on_connect_callback, ) - def close(self, reconnect: bool = False) -> None: - """Close connection.""" - super().close(reconnect=reconnect) - class ModbusSerialClient(ModbusBaseSyncClient): """**ModbusSerialClient**. diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py index 0a431a5d2..0f6f0de44 100644 --- a/pymodbus/client/tcp.py +++ b/pymodbus/client/tcp.py @@ -51,8 +51,6 @@ async def run(): Please refer to :ref:`Pymodbus internals` for advanced usage. """ - socket: socket.socket | None - def __init__( # pylint: disable=too-many-arguments self, host: str, @@ -85,10 +83,6 @@ def __init__( # pylint: disable=too-many-arguments on_connect_callback, ) - def close(self, reconnect: bool = False) -> None: - """Close connection.""" - super().close(reconnect=reconnect) - class ModbusTcpClient(ModbusBaseSyncClient): """**ModbusTcpClient**. diff --git a/pymodbus/client/udp.py b/pymodbus/client/udp.py index 23c6db759..296d1ae46 100644 --- a/pymodbus/client/udp.py +++ b/pymodbus/client/udp.py @@ -84,11 +84,6 @@ def __init__( # pylint: disable=too-many-arguments ) self.source_address = source_address - @property - def connected(self): - """Return true if connected.""" - return self.ctx.is_active() - class ModbusUdpClient(ModbusBaseSyncClient): """**ModbusUdpClient**. From 21099890bf2453967cb3e60cbb95e1caca53fe03 Mon Sep 17 00:00:00 2001 From: Yash Jani Date: Wed, 2 Oct 2024 00:54:07 +0530 Subject: [PATCH 30/41] Update README.rst (#2351) URL for Server Documentation Updated. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 1c93ea767..d3a96b418 100644 --- a/README.rst +++ b/README.rst @@ -84,7 +84,7 @@ Server Features * callback to intercept requests/responses * work on RS485 in parallel with other devices -`Server documentation `_ +`Server documentation `_ REPL Features From 577ffa670870d21029dab2f213a7f85e00687e64 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 1 Oct 2024 21:52:50 +0200 Subject: [PATCH 31/41] integrate old rtu framer in new framer (#2344) --- pymodbus/framer/old_framer_base.py | 3 + pymodbus/framer/old_framer_rtu.py | 94 ++++-------------------------- pymodbus/framer/rtu.py | 93 +++++++++++++++++------------ pymodbus/transaction.py | 5 +- test/framers/test_framer.py | 6 +- test/framers/test_old_framers.py | 31 ++++------ 6 files changed, 85 insertions(+), 147 deletions(-) diff --git a/pymodbus/framer/old_framer_base.py b/pymodbus/framer/old_framer_base.py index 8c357f26d..d20125392 100644 --- a/pymodbus/framer/old_framer_base.py +++ b/pymodbus/framer/old_framer_base.py @@ -46,6 +46,9 @@ def __init__( self.tid = 0 self.dev_id = 0 + def decode_data(self, _data): + """Decode data.""" + def _validate_slave_id(self, slaves: list, single: bool) -> bool: """Validate if the received data is valid for the client. diff --git a/pymodbus/framer/old_framer_rtu.py b/pymodbus/framer/old_framer_rtu.py index dcd311098..0d3866dda 100644 --- a/pymodbus/framer/old_framer_rtu.py +++ b/pymodbus/framer/old_framer_rtu.py @@ -1,6 +1,5 @@ """RTU framer.""" # pylint: disable=missing-type-doc -import struct import time from pymodbus.exceptions import ModbusIOException @@ -59,7 +58,7 @@ def __init__(self, decoder, client=None): super().__init__(decoder, client) self._hsize = 0x01 self.function_codes = decoder.lookup.keys() if decoder else {} - self.message_handler = FramerRTU() + self.message_handler: FramerRTU = FramerRTU(function_codes=self.function_codes, decoder=self.decoder) self.msg_len = 0 def decode_data(self, data): @@ -70,94 +69,21 @@ def decode_data(self, data): return {"slave": uid, "fcode": fcode} return {} - - def frameProcessIncomingPacket(self, _single, callback, slave, tid=None): # noqa: C901 + def frameProcessIncomingPacket(self, _single, callback, slave, tid=None): """Process new packet pattern.""" - - def is_frame_ready(self): - """Check if we should continue decode logic.""" - size = self.msg_len - if not size and len(self._buffer) > self._hsize: - try: - self.dev_id = int(self._buffer[0]) - func_code = int(self._buffer[1]) - pdu_class = self.decoder.lookupPduClass(func_code) - size = pdu_class.calculateRtuFrameSize(self._buffer) - self.msg_len = size - - if len(self._buffer) < size: - raise IndexError - except IndexError: - return False - return len(self._buffer) >= size if size > 0 else False - - def get_frame_start(self, slaves, broadcast, skip_cur_frame): - """Scan buffer for a relevant frame start.""" - start = 1 if skip_cur_frame else 0 - if (buf_len := len(self._buffer)) < 4: - return False - for i in range(start, buf_len - 3): # - if not broadcast and self._buffer[i] not in slaves: - continue - if ( - self._buffer[i + 1] not in self.function_codes - and (self._buffer[i + 1] - 0x80) not in self.function_codes - ): - continue - if i: - self._buffer = self._buffer[i:] # remove preceding trash. - return True - if buf_len > 3: - self._buffer = self._buffer[-3:] - return False - - def check_frame(self): - """Check if the next frame is available.""" - try: - self.dev_id = int(self._buffer[0]) - func_code = int(self._buffer[1]) - pdu_class = self.decoder.lookupPduClass(func_code) - size = pdu_class.calculateRtuFrameSize(self._buffer) - self.msg_len = size - - if len(self._buffer) < size: - raise IndexError - frame_size = self.msg_len - data = self._buffer[: frame_size - 2] - crc = self._buffer[size - 2 : size] - crc_val = (int(crc[0]) << 8) + int(crc[1]) - return FramerRTU.check_CRC(data, crc_val) - except (IndexError, KeyError, struct.error): - return False - - broadcast = not slave[0] - skip_cur_frame = False - while get_frame_start(self, slave, broadcast, skip_cur_frame): - self.dev_id = 0 - self.msg_len = 0 - if not is_frame_ready(self): - Log.debug("Frame - not ready") + self.message_handler.set_slaves(slave) + while True: + if self._buffer == b'': break - if not check_frame(self): - Log.debug("Frame check failed, ignoring!!") - x = self._buffer - self.resetFrame() - self._buffer: bytes = x - skip_cur_frame = True - continue - start = self._hsize - end = self.msg_len - 2 - buffer = self._buffer[start:end] - if end > 0: - Log.debug("Getting Frame - {}", buffer, ":hex") - data = buffer - else: - data = b"" + used_len, _, self.dev_id, data = self.message_handler.decode(self._buffer) + if used_len: + self._buffer = self._buffer[used_len:] + if not data: + break if (result := self.decoder.decode(data)) is None: raise ModbusIOException("Unable to decode request") result.slave_id = self.dev_id result.transaction_id = 0 - self._buffer = self._buffer[self.msg_len :] Log.debug("Frame advanced, resetting header!!") callback(result) # defer or push to a thread? diff --git a/pymodbus/framer/rtu.py b/pymodbus/framer/rtu.py index 9233245a1..2e9bc479e 100644 --- a/pymodbus/framer/rtu.py +++ b/pymodbus/framer/rtu.py @@ -1,8 +1,6 @@ """Modbus RTU frame implementation.""" from __future__ import annotations -from collections import namedtuple - from pymodbus.framer.base import FramerBase from pymodbus.logging import Log @@ -39,6 +37,7 @@ class FramerRTU(FramerBase): this means decoding is always exactly 1 frame request, however some requests will be for unknown slaves, which must be ignored together with the response from the unknown slave. + >>>>> NOT IMPLEMENTED <<<<< Recovery from bad cabling and unstable USB etc is important, the following scenarios is possible: @@ -52,17 +51,34 @@ class FramerRTU(FramerBase): Device drivers will typically flush buffer after 10ms of silence. If no data is received for 50ms the transmission / frame can be considered complete. - """ - MIN_SIZE = 5 + The following table is a listing of the baud wait times for the specified + baud rates:: + + ------------------------------------------------------------------ + Baud 1.5c (18 bits) 3.5c (38 bits) + ------------------------------------------------------------------ + 1200 13333.3 us 31666.7 us + 4800 3333.3 us 7916.7 us + 9600 1666.7 us 3958.3 us + 19200 833.3 us 1979.2 us + 38400 416.7 us 989.6 us + ... + ------------------------------------------------------------------ + 1 Byte = start + 8 bits + parity + stop = 11 bits + (1/Baud)(bits) = delay seconds + + >>>>> NOT IMPLEMENTED <<<<< + """ - FC_LEN = namedtuple("FC_LEN", "req_len req_bytepos resp_len resp_bytepos") + MIN_SIZE = 4 # - def __init__(self) -> None: + def __init__(self, function_codes=None, decoder=None) -> None: """Initialize a ADU instance.""" super().__init__() - self.fc_len: dict[int, FramerRTU.FC_LEN] = {} - + self.function_codes = function_codes + self.slaves: list[int] = [] + self.decoder = decoder @classmethod def generate_crc16_table(cls) -> list[int]: @@ -84,38 +100,41 @@ def generate_crc16_table(cls) -> list[int]: crc16_table: list[int] = [0] - def setup_fc_len(self, _fc: int, - _req_len: int, _req_byte_pos: int, - _resp_len: int, _resp_byte_pos: int - ): - """Define request/response lengths pr function code.""" - return + def set_slaves(self, slaves): + """Remember allowed slaves.""" + self.slaves = slaves def decode(self, data: bytes) -> tuple[int, int, int, bytes]: """Decode ADU.""" - if (buf_len := len(data)) < self.MIN_SIZE: - Log.debug("Short frame: {} wait for more data", data, ":hex") - return 0, 0, 0, b'' - - i = -1 - try: - while True: - i += 1 - if i > buf_len - self.MIN_SIZE + 1: - break - dev_id = int(data[i]) - fc_len = 5 - msg_len = fc_len -2 if fc_len > 0 else int(data[i-fc_len])-fc_len+1 - if msg_len + i + 2 > buf_len: - break - crc_val = (int(data[i+msg_len]) << 8) + int(data[i+msg_len+1]) - if not self.check_CRC(data[i:i+msg_len], crc_val): - Log.debug("Skipping frame CRC with len {} at index {}!", msg_len, i) - raise KeyError - return i+msg_len+2, dev_id, dev_id, data[i+1:i+msg_len] - except KeyError: - i = buf_len - return i, 0, 0, b'' + msg_len = len(data) + for used_len in range(msg_len): + if msg_len - used_len < self.MIN_SIZE: + Log.debug("Short frame: {} wait for more data", data, ":hex") + return 0, 0, 0, b'' + dev_id = int(data[used_len]) + func_code = int(data[used_len + 1]) + if (self.slaves[0] and dev_id not in self.slaves) or func_code & 0x7F not in self.function_codes: + continue + if msg_len - used_len < self.MIN_SIZE: + Log.debug("Garble in front {}, then short frame: {} wait for more data", used_len, data, ":hex") + return used_len, 0, 0, b'' + pdu_class = self.decoder.lookupPduClass(func_code) + try: + size = pdu_class.calculateRtuFrameSize(data[used_len:]) + except IndexError: + size = msg_len +1 + if msg_len < used_len +size: + Log.debug("Frame - not ready") + return used_len, 0, 0, b'' + start_crc = used_len + size -2 + crc = data[start_crc : start_crc + 2] + crc_val = (int(crc[0]) << 8) + int(crc[1]) + if not FramerRTU.check_CRC(data[used_len : start_crc], crc_val): + Log.debug("Frame check failed, ignoring!!") + return used_len, 0, 0, b'' + + return start_crc + 2, 0, dev_id, data[used_len + 1 : start_crc] + return used_len, 0, 0, b'' def encode(self, pdu: bytes, device_id: int, _tid: int) -> bytes: diff --git a/pymodbus/transaction.py b/pymodbus/transaction.py index a7fad3d7b..25c536337 100644 --- a/pymodbus/transaction.py +++ b/pymodbus/transaction.py @@ -184,10 +184,7 @@ def _validate_response(self, request: ModbusRequest, response, exp_resp_len, is_ if not response: return False - if hasattr(self.client.framer, "decode_data"): - mbap = self.client.framer.decode_data(response) - else: - mbap = {} + mbap = self.client.framer.decode_data(response) if ( mbap.get("slave") != request.slave_id or mbap.get("fcode") & 0x7F != request.function_code diff --git a/test/framers/test_framer.py b/test/framers/test_framer.py index 725197fbc..2ba0020ca 100644 --- a/test/framers/test_framer.py +++ b/test/framers/test_framer.py @@ -348,9 +348,9 @@ async def test_decode_type(self, entry, dummy_framer, data, dev_id, tr_id, expec (12, b"\x03\x00\x7c\x00\x02"), (12, b"\x03\x00\x7c\x00\x02"), ]), - (FramerType.RTU, b'\x00\x83\x02\x91\x21', [ # bad crc - (5, b''), - ]), + # (FramerType.RTU, b'\x00\x83\x02\x91\x21', [ # bad crc + # (5, b''), + #]), #(FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31', [ # dummy char in stream, bad crc # (5, b''), #]), diff --git a/test/framers/test_old_framers.py b/test/framers/test_old_framers.py index 91adb6864..8c85a832e 100644 --- a/test/framers/test_old_framers.py +++ b/test/framers/test_old_framers.py @@ -196,8 +196,9 @@ def callback(data): count += 1 result = data + rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert rtu_framer.dev_id == dev_id + assert result.slave_id == dev_id def test_get_frame(self, rtu_framer): @@ -225,42 +226,38 @@ def test_populate_result(self, rtu_framer): @pytest.mark.parametrize( - ("data", "slaves", "reset_called", "cb_called"), + ("data", "slaves", "cb_called"), [ - (b"\x11", [17], 0, 0), # not complete frame - (b"\x11\x03", [17], 0, 0), # not complete frame - (b"\x11\x03\x06", [17], 0, 0), # not complete frame - (b"\x11\x03\x06\xAE\x41\x56\x52\x43", [17], 0, 0), # not complete frame + (b"\x11", [17], 0), # not complete frame + (b"\x11\x03", [17], 0), # not complete frame + (b"\x11\x03\x06", [17], 0), # not complete frame + (b"\x11\x03\x06\xAE\x41\x56\x52\x43", [17], 0), # not complete frame ( b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40", [17], 0, - 0, ), # not complete frame ( b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49", [17], 0, - 0, ), # not complete frame - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAC", [17], 1, 0), # bad crc + (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAC", [17], 0), # bad crc ( b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", [17], - 0, 1, ), # good frame ( b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", [16], 0, - 0, ), # incorrect slave id - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x11\x03", [17], 0, 1), + (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x11\x03", [17], 1), # good frame + part of next frame ], ) - def test_rtu_incoming_packet(self, rtu_framer, data, slaves, reset_called, cb_called): + def test_rtu_incoming_packet(self, rtu_framer, data, slaves, cb_called): """Test rtu process incoming packet.""" count = 0 result = None @@ -270,12 +267,8 @@ def callback(data): count += 1 result = data - with mock.patch.object( - rtu_framer, "resetFrame", wraps=rtu_framer.resetFrame - ) as mock_reset: - rtu_framer.processIncomingPacket(data, callback, slaves) - assert count == cb_called - assert mock_reset.call_count == reset_called + rtu_framer.processIncomingPacket(data, callback, slaves) + assert count == cb_called async def test_send_packet(self, rtu_framer): From 272fc8d8a82ff4fafbbbaaf894834a2419768759 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 4 Oct 2024 11:46:51 +0200 Subject: [PATCH 32/41] Sync client, allow unknown recv msg size. (#2353) --- pymodbus/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pymodbus/transaction.py b/pymodbus/transaction.py index 25c536337..041fc93b2 100644 --- a/pymodbus/transaction.py +++ b/pymodbus/transaction.py @@ -378,7 +378,7 @@ def _recv(self, expected_response_length, full) -> bytes: # noqa: C901 min_size = expected_response_length read_min = self.client.framer.recvPacket(min_size) - if len(read_min) != min_size: + if min_size and len(read_min) != min_size: msg_start = "Incomplete message" if read_min else "No response" raise InvalidMessageReceivedException( f"{msg_start} received, expected at least {min_size} bytes " From 9ca9a7b4ede6c4cf5e8b5123d08fbe7897824236 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 4 Oct 2024 19:30:57 +0200 Subject: [PATCH 33/41] Run CI on PR targeted at wait_next_api. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb9a4671d..0335f5ce6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ on: pull_request: branches: - dev + - wait_next_api types: [opened, synchronize, reopened, ready_for_review] schedule: # Sunday at 02:10 UTC. From 1d9f64e965b8b05d366d04a89ed4b64389351e31 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 6 Oct 2024 22:02:29 +0200 Subject: [PATCH 34/41] Cleanup framers (reduce old_framers) (#2342) --- pymodbus/client/base.py | 4 +- pymodbus/client/modbusclientprotocol.py | 4 +- pymodbus/framer/__init__.py | 23 +- pymodbus/framer/ascii.py | 16 +- pymodbus/framer/base.py | 39 +- pymodbus/framer/framer.py | 21 +- pymodbus/framer/old_framer_ascii.py | 18 +- pymodbus/framer/old_framer_base.py | 5 - pymodbus/framer/old_framer_rtu.py | 33 +- pymodbus/framer/old_framer_socket.py | 28 +- pymodbus/framer/old_framer_tls.py | 26 +- pymodbus/framer/raw.py | 32 - pymodbus/framer/rtu.py | 43 +- pymodbus/framer/socket.py | 17 +- pymodbus/framer/tls.py | 4 +- pymodbus/server/async_io.py | 4 +- pymodbus/transaction.py | 28 +- test/framers/conftest.py | 42 +- test/framers/test_ascii.py | 55 -- test/framers/test_asyncframer.py | 391 ++++++++++ test/framers/test_framer.py | 177 ++--- ...ver_multidrop_tbd.py => test_multidrop.py} | 2 +- test/framers/test_old_framers.py | 476 ------------ test/framers/test_rtu.py | 54 -- test/framers/test_socket.py | 53 -- test/framers/test_tbc_transaction.py | 690 ------------------ test/framers/test_tls.py | 49 -- test/sub_current/test_transaction.py | 15 - 28 files changed, 605 insertions(+), 1744 deletions(-) delete mode 100644 pymodbus/framer/raw.py delete mode 100644 test/framers/test_ascii.py create mode 100644 test/framers/test_asyncframer.py rename test/framers/{server_multidrop_tbd.py => test_multidrop.py} (99%) delete mode 100644 test/framers/test_old_framers.py delete mode 100644 test/framers/test_rtu.py delete mode 100644 test/framers/test_socket.py delete mode 100755 test/framers/test_tbc_transaction.py delete mode 100644 test/framers/test_tls.py diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 06bbe0a1f..cf541724c 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -11,7 +11,7 @@ from pymodbus.client.modbusclientprotocol import ModbusClientProtocol from pymodbus.exceptions import ConnectionException, ModbusIOException from pymodbus.factory import ClientDecoder -from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerType, ModbusFramer +from pymodbus.framer import FRAMER_NAME_TO_OLD_CLASS, FramerType, ModbusFramer from pymodbus.logging import Log from pymodbus.pdu import ModbusRequest, ModbusResponse from pymodbus.transaction import SyncModbusTransactionManager @@ -188,7 +188,7 @@ def __init__( self.slaves: list[int] = [] # Common variables. - self.framer: ModbusFramer = FRAMER_NAME_TO_CLASS.get( + self.framer: ModbusFramer = FRAMER_NAME_TO_OLD_CLASS.get( framer, cast(type[ModbusFramer], framer) )(ClientDecoder(), self) self.transaction = SyncModbusTransactionManager( diff --git a/pymodbus/client/modbusclientprotocol.py b/pymodbus/client/modbusclientprotocol.py index fffe822e2..22de94fa1 100644 --- a/pymodbus/client/modbusclientprotocol.py +++ b/pymodbus/client/modbusclientprotocol.py @@ -5,7 +5,7 @@ from typing import cast from pymodbus.factory import ClientDecoder -from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerType, ModbusFramer +from pymodbus.framer import FRAMER_NAME_TO_OLD_CLASS, FramerType, ModbusFramer from pymodbus.logging import Log from pymodbus.transaction import ModbusTransactionManager from pymodbus.transport import CommParams, ModbusProtocol @@ -32,7 +32,7 @@ def __init__( self.on_connect_callback = on_connect_callback # Common variables. - self.framer = FRAMER_NAME_TO_CLASS.get( + self.framer = FRAMER_NAME_TO_OLD_CLASS.get( framer, cast(type[ModbusFramer], framer) )(ClientDecoder(), self) self.transaction = ModbusTransactionManager() diff --git a/pymodbus/framer/__init__.py b/pymodbus/framer/__init__.py index 32c61d817..9d17d2842 100644 --- a/pymodbus/framer/__init__.py +++ b/pymodbus/framer/__init__.py @@ -1,27 +1,40 @@ """Framer.""" __all__ = [ - "Framer", - "FRAMER_NAME_TO_CLASS", + "FRAMER_NAME_TO_OLD_CLASS", "ModbusFramer", "ModbusAsciiFramer", "ModbusRtuFramer", "ModbusSocketFramer", "ModbusTlsFramer", - "Framer", + "AsyncFramer", "FramerType", + "FramerAscii", + "FramerRTU", + "FramerSocket", + "FramerTLS" ] -from pymodbus.framer.framer import Framer, FramerType +from pymodbus.framer.ascii import FramerAscii +from pymodbus.framer.framer import AsyncFramer, FramerType from pymodbus.framer.old_framer_ascii import ModbusAsciiFramer from pymodbus.framer.old_framer_base import ModbusFramer from pymodbus.framer.old_framer_rtu import ModbusRtuFramer from pymodbus.framer.old_framer_socket import ModbusSocketFramer from pymodbus.framer.old_framer_tls import ModbusTlsFramer +from pymodbus.framer.rtu import FramerRTU +from pymodbus.framer.socket import FramerSocket +from pymodbus.framer.tls import FramerTLS -FRAMER_NAME_TO_CLASS = { +FRAMER_NAME_TO_OLD_CLASS = { FramerType.ASCII: ModbusAsciiFramer, FramerType.RTU: ModbusRtuFramer, FramerType.SOCKET: ModbusSocketFramer, FramerType.TLS: ModbusTlsFramer, } +FRAMER_NAME_TO_CLASS = { + FramerType.ASCII: FramerAscii, + FramerType.RTU: FramerRTU, + FramerType.SOCKET: FramerSocket, + FramerType.TLS: FramerTLS, +} diff --git a/pymodbus/framer/ascii.py b/pymodbus/framer/ascii.py index 0229ed19f..48fbb67eb 100644 --- a/pymodbus/framer/ascii.py +++ b/pymodbus/framer/ascii.py @@ -32,31 +32,31 @@ class FramerAscii(FramerBase): MIN_SIZE = 10 - def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: """Decode ADU.""" - buf_len = len(data) used_len = 0 while True: - if buf_len - used_len < self.MIN_SIZE: - return used_len, 0, 0, self.EMPTY + if data_len - used_len < self.MIN_SIZE: + return used_len, self.EMPTY buffer = data[used_len:] if buffer[0:1] != self.START: if (i := buffer.find(self.START)) == -1: Log.debug("No frame start in data: {}, wait for data", data, ":hex") - return buf_len, 0, 0, self.EMPTY + return data_len, self.EMPTY used_len += i continue if (end := buffer.find(self.END)) == -1: Log.debug("Incomplete frame: {} wait for more data", data, ":hex") - return used_len, 0, 0, self.EMPTY - dev_id = int(buffer[1:3], 16) + return used_len, self.EMPTY + self.incoming_dev_id = int(buffer[1:3], 16) + self.incoming_tid = self.incoming_dev_id lrc = int(buffer[end - 2: end], 16) msg = a2b_hex(buffer[1 : end - 2]) used_len += end + 2 if not self.check_LRC(msg, lrc): Log.debug("LRC wrong in frame: {} skipping", data, ":hex") continue - return used_len, dev_id, dev_id, msg[1:] + return used_len, msg[1:] def encode(self, data: bytes, device_id: int, _tid: int) -> bytes: """Encode ADU.""" diff --git a/pymodbus/framer/base.py b/pymodbus/framer/base.py index e0c1595f0..326f66978 100644 --- a/pymodbus/framer/base.py +++ b/pymodbus/framer/base.py @@ -8,32 +8,53 @@ from abc import abstractmethod +from pymodbus.factory import ClientDecoder, ServerDecoder +from pymodbus.logging import Log + class FramerBase: """Intern base.""" EMPTY = b'' + MIN_SIZE = 0 - def __init__(self) -> None: - """Initialize a ADU instance.""" + def __init__( + self, + decoder: ClientDecoder | ServerDecoder, + dev_ids: list[int], + ) -> None: + """Initialize a ADU (framer) instance.""" + self.decoder = decoder + self.dev_ids = dev_ids + self.incoming_dev_id = 0 + self.incoming_tid = 0 - def set_dev_ids(self, _dev_ids: list[int]): - """Set/update allowed device ids.""" + def decode(self, data: bytes) -> tuple[int, bytes]: + """Decode ADU. - def set_fc_calc(self, _fc: int, _msg_size: int, _count_pos: int): - """Set/Update function code information.""" + returns: + used_len (int) or 0 to read more + modbus request/response (bytes) + """ + if (data_len := len(data)) < self.MIN_SIZE: + Log.debug("Very short frame (NO MBAP): {} wait for more data", data, ":hex") + return 0, self.EMPTY + used_len, res_data = self.specific_decode(data, data_len) + if not res_data: + self.incoming_dev_id = 0 + self.incoming_tid = 0 + return used_len, res_data @abstractmethod - def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: """Decode ADU. returns: used_len (int) or 0 to read more - transaction_id (int) or 0 - device_id (int) or 0 modbus request/response (bytes) """ + @abstractmethod def encode(self, pdu: bytes, dev_id: int, tid: int) -> bytes: """Encode ADU. diff --git a/pymodbus/framer/framer.py b/pymodbus/framer/framer.py index b2f8be800..52eccf1de 100644 --- a/pymodbus/framer/framer.py +++ b/pymodbus/framer/framer.py @@ -12,8 +12,8 @@ from abc import abstractmethod from enum import Enum +from pymodbus.factory import ClientDecoder, ServerDecoder from pymodbus.framer.ascii import FramerAscii -from pymodbus.framer.raw import FramerRaw from pymodbus.framer.rtu import FramerRTU from pymodbus.framer.socket import FramerSocket from pymodbus.framer.tls import FramerTLS @@ -23,14 +23,13 @@ class FramerType(str, Enum): """Type of Modbus frame.""" - RAW = "raw" # only used for testing ASCII = "ascii" RTU = "rtu" SOCKET = "socket" TLS = "tls" -class Framer(ModbusProtocol): +class AsyncFramer(ModbusProtocol): """Framer layer extending transport layer. extends the ModbusProtocol to handle receiving and sending of complete modbus PDU. @@ -54,6 +53,7 @@ def __init__(self, framer_type: FramerType, params: CommParams, is_server: bool, + decoder: ClientDecoder | ServerDecoder, device_ids: list[int], ): """Initialize a framer instance. @@ -68,11 +68,10 @@ def __init__(self, self.broadcast: bool = (0 in device_ids) self.handle = { - FramerType.RAW: FramerRaw(), - FramerType.ASCII: FramerAscii(), - FramerType.RTU: FramerRTU(), - FramerType.SOCKET: FramerSocket(), - FramerType.TLS: FramerTLS(), + FramerType.ASCII: FramerAscii(decoder, device_ids), + FramerType.RTU: FramerRTU(decoder, device_ids), + FramerType.SOCKET: FramerSocket(decoder, device_ids), + FramerType.TLS: FramerTLS(decoder, device_ids), }[framer_type] @@ -81,11 +80,11 @@ def callback_data(self, data: bytes, addr: tuple | None = None) -> int: tot_len = 0 buf_len = len(data) while True: - used_len, tid, device_id, msg = self.handle.decode(data[tot_len:]) + used_len, msg = self.handle.decode(data[tot_len:]) tot_len += used_len if msg: - if self.broadcast or device_id in self.device_ids: - self.callback_request_response(msg, device_id, tid) + if self.broadcast or self.handle.incoming_dev_id in self.device_ids: + self.callback_request_response(msg, self.handle.incoming_dev_id, self.handle.incoming_tid) if tot_len == buf_len: return tot_len else: diff --git a/pymodbus/framer/old_framer_ascii.py b/pymodbus/framer/old_framer_ascii.py index 780d1cf9b..753bc4633 100644 --- a/pymodbus/framer/old_framer_ascii.py +++ b/pymodbus/framer/old_framer_ascii.py @@ -27,8 +27,6 @@ class ModbusAsciiFramer(ModbusFramer): the data in this framer is transferred in plain text ascii. """ - method = "ascii" - def __init__(self, decoder, client=None): """Initialize a new instance of the framer. @@ -38,28 +36,20 @@ def __init__(self, decoder, client=None): self._hsize = 0x02 self._start = b":" self._end = b"\r\n" - self.message_handler = FramerAscii() - - def decode_data(self, data): - """Decode data.""" - if len(data) > 1: - uid = int(data[1:3], 16) - fcode = int(data[3:5], 16) - return {"slave": uid, "fcode": fcode} - return {} + self.message_handler = FramerAscii(decoder, [0]) def frameProcessIncomingPacket(self, single, callback, slave, tid=None): """Process new packet pattern.""" while len(self._buffer): - used_len, tid, dev_id, data = self.message_handler.decode(self._buffer) + used_len, data = self.message_handler.decode(self._buffer) if not data: if not used_len: return self._buffer = self._buffer[used_len :] continue - self.dev_id = dev_id + self.dev_id = self.message_handler.incoming_dev_id if not self._validate_slave_id(slave, single): - Log.error("Not a valid slave id - {}, ignoring!!", dev_id) + Log.error("Not a valid slave id - {}, ignoring!!", self.message_handler.incoming_dev_id) self.resetFrame() return diff --git a/pymodbus/framer/old_framer_base.py b/pymodbus/framer/old_framer_base.py index d20125392..1b425f6f9 100644 --- a/pymodbus/framer/old_framer_base.py +++ b/pymodbus/framer/old_framer_base.py @@ -28,8 +28,6 @@ class ModbusFramer: """Base Framer class.""" - name = "" - def __init__( self, decoder: ClientDecoder | ServerDecoder, @@ -46,9 +44,6 @@ def __init__( self.tid = 0 self.dev_id = 0 - def decode_data(self, _data): - """Decode data.""" - def _validate_slave_id(self, slaves: list, single: bool) -> bool: """Validate if the received data is valid for the client. diff --git a/pymodbus/framer/old_framer_rtu.py b/pymodbus/framer/old_framer_rtu.py index 0d3866dda..c7c65a0ea 100644 --- a/pymodbus/framer/old_framer_rtu.py +++ b/pymodbus/framer/old_framer_rtu.py @@ -1,5 +1,4 @@ """RTU framer.""" -# pylint: disable=missing-type-doc import time from pymodbus.exceptions import ModbusIOException @@ -48,34 +47,21 @@ class ModbusRtuFramer(ModbusFramer): (1/Baud)(bits) = delay seconds """ - method = "rtu" - def __init__(self, decoder, client=None): """Initialize a new instance of the framer. :param decoder: The decoder factory implementation to use """ super().__init__(decoder, client) - self._hsize = 0x01 - self.function_codes = decoder.lookup.keys() if decoder else {} - self.message_handler: FramerRTU = FramerRTU(function_codes=self.function_codes, decoder=self.decoder) - self.msg_len = 0 - - def decode_data(self, data): - """Decode data.""" - if len(data) > self._hsize: - uid = int(data[0]) - fcode = int(data[1]) - return {"slave": uid, "fcode": fcode} - return {} + self.message_handler: FramerRTU = FramerRTU(self.decoder, [0]) - def frameProcessIncomingPacket(self, _single, callback, slave, tid=None): + def frameProcessIncomingPacket(self, _single, callback, _slave, tid=None): """Process new packet pattern.""" - self.message_handler.set_slaves(slave) while True: if self._buffer == b'': break - used_len, _, self.dev_id, data = self.message_handler.decode(self._buffer) + used_len, data = self.message_handler.decode(self._buffer) + self.dev_id = self.message_handler.incoming_dev_id if used_len: self._buffer = self._buffer[used_len:] if not data: @@ -87,17 +73,6 @@ def frameProcessIncomingPacket(self, _single, callback, slave, tid=None): Log.debug("Frame advanced, resetting header!!") callback(result) # defer or push to a thread? - def buildPacket(self, message): - """Create a ready to send modbus packet. - - :param message: The populated request/response to send - """ - packet = super().buildPacket(message) - - # Ensure that transaction is actually the slave id for serial comms - message.transaction_id = 0 - return packet - def sendPacket(self, message: bytes) -> int: """Send packets on the bus with 3.5char delay between frames. diff --git a/pymodbus/framer/old_framer_socket.py b/pymodbus/framer/old_framer_socket.py index 88668566f..520be9cdd 100644 --- a/pymodbus/framer/old_framer_socket.py +++ b/pymodbus/framer/old_framer_socket.py @@ -1,10 +1,9 @@ """Socket framer.""" -import struct from pymodbus.exceptions import ( ModbusIOException, ) -from pymodbus.framer.old_framer_base import SOCKET_FRAME_HEADER, ModbusFramer +from pymodbus.framer.old_framer_base import ModbusFramer from pymodbus.framer.socket import FramerSocket from pymodbus.logging import Log @@ -33,8 +32,6 @@ class ModbusSocketFramer(ModbusFramer): * The -1 is to account for the uid byte """ - method = "socket" - def __init__(self, decoder, client=None): """Initialize a new instance of the framer. @@ -42,20 +39,7 @@ def __init__(self, decoder, client=None): """ super().__init__(decoder, client) self._hsize = 0x07 - self.message_handler = FramerSocket() - - def decode_data(self, data): - """Decode data.""" - if len(data) > self._hsize: - _tid, _pid, length, uid, fcode = struct.unpack( - SOCKET_FRAME_HEADER, data[0 : self._hsize + 1] - ) - return { - "length": length, - "slave": uid, - "fcode": fcode, - } - return {} + self.message_handler = FramerSocket(decoder, [0]) def frameProcessIncomingPacket(self, single, callback, slave, tid=None): """Process new packet pattern. @@ -72,13 +56,13 @@ def frameProcessIncomingPacket(self, single, callback, slave, tid=None): while True: if self._buffer == b'': return - used_len, use_tid, dev_id, data = self.message_handler.decode(self._buffer) + used_len, data = self.message_handler.decode(self._buffer) if not data: return - self.dev_id = dev_id - self.tid = use_tid + self.dev_id = self.message_handler.incoming_dev_id + self.tid = self.message_handler.incoming_tid if not self._validate_slave_id(slave, single): - Log.debug("Not a valid slave id - {}, ignoring!!", dev_id) + Log.debug("Not a valid slave id - {}, ignoring!!", self.message_handler.incoming_dev_id) self.resetFrame() return if (result := self.decoder.decode(data)) is None: diff --git a/pymodbus/framer/old_framer_tls.py b/pymodbus/framer/old_framer_tls.py index ae42d1f99..196ca7627 100644 --- a/pymodbus/framer/old_framer_tls.py +++ b/pymodbus/framer/old_framer_tls.py @@ -1,11 +1,9 @@ """TLS framer.""" -import struct -from time import sleep from pymodbus.exceptions import ( ModbusIOException, ) -from pymodbus.framer.old_framer_base import TLS_FRAME_HEADER, ModbusFramer +from pymodbus.framer.old_framer_base import ModbusFramer from pymodbus.framer.tls import FramerTLS @@ -25,8 +23,6 @@ class ModbusTlsFramer(ModbusFramer): 1b Nb """ - method = "tls" - def __init__(self, decoder, client=None): """Initialize a new instance of the framer. @@ -34,30 +30,18 @@ def __init__(self, decoder, client=None): """ super().__init__(decoder, client) self._hsize = 0x0 - self.message_handler = FramerTLS() - - def decode_data(self, data): - """Decode data.""" - if len(data) > self._hsize: - (fcode,) = struct.unpack(TLS_FRAME_HEADER, data[0 : self._hsize + 1]) - return {"fcode": fcode} - return {} - - def recvPacket(self, size): - """Receive packet from the bus.""" - sleep(0.5) - return super().recvPacket(size) + self.message_handler = FramerTLS(decoder, [0]) def frameProcessIncomingPacket(self, _single, callback, _slave, tid=None): """Process new packet pattern.""" # no slave id for Modbus Security Application Protocol while True: - used_len, use_tid, dev_id, data = self.message_handler.decode(self._buffer) + used_len, data = self.message_handler.decode(self._buffer) if not data: return - self.dev_id = dev_id - self.tid = use_tid + self.dev_id = self.message_handler.incoming_dev_id + self.tid = self.message_handler.incoming_tid if (result := self.decoder.decode(data)) is None: self.resetFrame() diff --git a/pymodbus/framer/raw.py b/pymodbus/framer/raw.py deleted file mode 100644 index 96ca1bc2e..000000000 --- a/pymodbus/framer/raw.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Modbus Raw (passthrough) implementation.""" -from __future__ import annotations - -from pymodbus.framer.base import FramerBase -from pymodbus.logging import Log - - -class FramerRaw(FramerBase): - r"""Modbus RAW Frame Controller. - - [ Device id ][Transaction id ][ Data ] - 1b 2b Nb - - * data can be 0 - X bytes - - This framer is used for non modbus communication and testing purposes. - """ - - MIN_SIZE = 3 - - def decode(self, data: bytes) -> tuple[int, int, int, bytes]: - """Decode ADU.""" - if len(data) < self.MIN_SIZE: - Log.debug("Short frame: {} wait for more data", data, ":hex") - return 0, 0, 0, self.EMPTY - dev_id = int(data[0]) - tid = int(data[1]) - return len(data), dev_id, tid, data[2:] - - def encode(self, pdu: bytes, dev_id: int, tid: int) -> bytes: - """Encode ADU.""" - return dev_id.to_bytes(1, 'big') + tid.to_bytes(1, 'big') + pdu diff --git a/pymodbus/framer/rtu.py b/pymodbus/framer/rtu.py index 2e9bc479e..76b9da54c 100644 --- a/pymodbus/framer/rtu.py +++ b/pymodbus/framer/rtu.py @@ -73,13 +73,6 @@ class FramerRTU(FramerBase): MIN_SIZE = 4 # - def __init__(self, function_codes=None, decoder=None) -> None: - """Initialize a ADU instance.""" - super().__init__() - self.function_codes = function_codes - self.slaves: list[int] = [] - self.decoder = decoder - @classmethod def generate_crc16_table(cls) -> list[int]: """Generate a crc16 lookup table. @@ -100,41 +93,37 @@ def generate_crc16_table(cls) -> list[int]: crc16_table: list[int] = [0] - def set_slaves(self, slaves): - """Remember allowed slaves.""" - self.slaves = slaves - - def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: """Decode ADU.""" - msg_len = len(data) - for used_len in range(msg_len): - if msg_len - used_len < self.MIN_SIZE: + for used_len in range(data_len): + if data_len - used_len < self.MIN_SIZE: Log.debug("Short frame: {} wait for more data", data, ":hex") - return 0, 0, 0, b'' - dev_id = int(data[used_len]) + return used_len, self.EMPTY + self.incoming_dev_id = int(data[used_len]) func_code = int(data[used_len + 1]) - if (self.slaves[0] and dev_id not in self.slaves) or func_code & 0x7F not in self.function_codes: + if (self.dev_ids[0] and self.incoming_dev_id not in self.dev_ids) or func_code & 0x7F not in self.decoder.lookup: continue - if msg_len - used_len < self.MIN_SIZE: + if data_len - used_len < self.MIN_SIZE: Log.debug("Garble in front {}, then short frame: {} wait for more data", used_len, data, ":hex") - return used_len, 0, 0, b'' + return used_len, self.EMPTY pdu_class = self.decoder.lookupPduClass(func_code) try: size = pdu_class.calculateRtuFrameSize(data[used_len:]) except IndexError: - size = msg_len +1 - if msg_len < used_len +size: + size = data_len +1 + if data_len < used_len +size: Log.debug("Frame - not ready") - return used_len, 0, 0, b'' + if used_len: + continue + return used_len, self.EMPTY start_crc = used_len + size -2 crc = data[start_crc : start_crc + 2] crc_val = (int(crc[0]) << 8) + int(crc[1]) if not FramerRTU.check_CRC(data[used_len : start_crc], crc_val): Log.debug("Frame check failed, ignoring!!") - return used_len, 0, 0, b'' - - return start_crc + 2, 0, dev_id, data[used_len + 1 : start_crc] - return used_len, 0, 0, b'' + continue + return start_crc + 2, data[used_len + 1 : start_crc] + return used_len, self.EMPTY def encode(self, pdu: bytes, device_id: int, _tid: int) -> bytes: diff --git a/pymodbus/framer/socket.py b/pymodbus/framer/socket.py index 793e37f8e..53de210fc 100644 --- a/pymodbus/framer/socket.py +++ b/pymodbus/framer/socket.py @@ -17,20 +17,17 @@ class FramerSocket(FramerBase): MIN_SIZE = 8 - def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: """Decode ADU.""" - if (used_len := len(data)) < self.MIN_SIZE: - Log.debug("Very short frame (NO MBAP): {} wait for more data", data, ":hex") - return 0, 0, 0, self.EMPTY - msg_tid = int.from_bytes(data[0:2], 'big') + self.incoming_tid = int.from_bytes(data[0:2], 'big') msg_len = int.from_bytes(data[4:6], 'big') + 6 - msg_dev = int(data[6]) - if used_len < msg_len: + self.incoming_dev_id = int(data[6]) + if data_len < msg_len: Log.debug("Short frame: {} wait for more data", data, ":hex") - return 0, 0, 0, self.EMPTY - if msg_len == 8 and used_len == 9: + return 0, self.EMPTY + if msg_len == 8 and data_len == 9: msg_len = 9 - return msg_len, msg_tid, msg_dev, data[7:msg_len] + return msg_len, data[7:msg_len] def encode(self, pdu: bytes, device_id: int, tid: int) -> bytes: """Encode ADU.""" diff --git a/pymodbus/framer/tls.py b/pymodbus/framer/tls.py index a4e83973b..4565a6811 100644 --- a/pymodbus/framer/tls.py +++ b/pymodbus/framer/tls.py @@ -11,9 +11,9 @@ class FramerTLS(FramerBase): 1b Nb """ - def decode(self, data: bytes) -> tuple[int, int, int, bytes]: + def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: """Decode ADU.""" - return len(data), 0, 0, data + return data_len, data def encode(self, pdu: bytes, _device_id: int, _tid: int) -> bytes: """Encode ADU.""" diff --git a/pymodbus/server/async_io.py b/pymodbus/server/async_io.py index 593a4d254..ad1dc7758 100644 --- a/pymodbus/server/async_io.py +++ b/pymodbus/server/async_io.py @@ -11,7 +11,7 @@ from pymodbus.device import ModbusControlBlock, ModbusDeviceIdentification from pymodbus.exceptions import NoSuchSlaveException from pymodbus.factory import ServerDecoder -from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerType, ModbusFramer +from pymodbus.framer import FRAMER_NAME_TO_OLD_CLASS, FramerType, ModbusFramer from pymodbus.logging import Log from pymodbus.pdu import ModbusExceptions as merror from pymodbus.transport import CommParams, CommType, ModbusProtocol @@ -274,7 +274,7 @@ def __init__( if isinstance(identity, ModbusDeviceIdentification): self.control.Identity.update(identity) - self.framer = FRAMER_NAME_TO_CLASS.get(framer, framer) + self.framer = FRAMER_NAME_TO_OLD_CLASS.get(framer, framer) self.serving: asyncio.Future = asyncio.Future() def callback_new_connection(self): diff --git a/pymodbus/transaction.py b/pymodbus/transaction.py index 041fc93b2..c6f9a6dd8 100644 --- a/pymodbus/transaction.py +++ b/pymodbus/transaction.py @@ -173,26 +173,10 @@ def _calculate_exception_length(self): return self.base_adu_size + 2 # Fcode(1), ExceptionCode(1) return None - def _validate_response(self, request: ModbusRequest, response, exp_resp_len, is_udp=False): - """Validate Incoming response against request. - - :param request: Request sent - :param response: Response received - :param exp_resp_len: Expected response length - :return: New transactions state - """ + def _validate_response(self, response): + """Validate Incoming response against request.""" if not response: return False - - mbap = self.client.framer.decode_data(response) - if ( - mbap.get("slave") != request.slave_id - or mbap.get("fcode") & 0x7F != request.function_code - ): - return False - - if "length" in mbap and exp_resp_len and not is_udp: - return mbap.get("length") == exp_resp_len return True def execute(self, request: ModbusRequest): # noqa: C901 @@ -228,9 +212,7 @@ def execute(self, request: ModbusRequest): # noqa: C901 full = True else: full = False - is_udp = False if self.client.comm_params.comm_type == CommType.UDP: - is_udp = True full = True if not expected_response_length: expected_response_length = 1024 @@ -241,11 +223,7 @@ def execute(self, request: ModbusRequest): # noqa: C901 broadcast=broadcast, ) while retries > 0: - valid_response = self._validate_response( - request, response, expected_response_length, - is_udp=is_udp - ) - if valid_response: + if self._validate_response(response): if ( request.slave_id in self._no_response_devices and response diff --git a/test/framers/conftest.py b/test/framers/conftest.py index e5cb9304d..851c2e69c 100644 --- a/test/framers/conftest.py +++ b/test/framers/conftest.py @@ -6,30 +6,48 @@ import pytest from pymodbus.factory import ClientDecoder, ServerDecoder -from pymodbus.framer import Framer, FramerType +from pymodbus.framer import FRAMER_NAME_TO_CLASS, AsyncFramer, FramerType from pymodbus.transport import CommParams @pytest.fixture(name="entry") def prepare_entry(): """Return framer_type.""" - return FramerType.RAW + return FramerType.ASCII @pytest.fixture(name="is_server") def prepare_is_server(): """Return client/server.""" return False -@mock.patch.multiple(Framer, __abstractmethods__=set()) # eliminate abstract methods (callbacks) -@pytest.fixture(name="dummy_framer") -async def prepare_test_framer(entry, is_server): +@pytest.fixture(name="dev_ids") +def prepare_dev_ids(): + """Return list of device ids.""" + return [0, 17] + +@pytest.fixture(name="test_framer") +async def prepare_test_framer(entry, is_server, dev_ids): + """Return framer object.""" + return FRAMER_NAME_TO_CLASS[entry]( + (ServerDecoder if is_server else ClientDecoder)(), + dev_ids, + ) + + + + + +@mock.patch.multiple(AsyncFramer, __abstractmethods__=set()) # eliminate abstract methods (callbacks) +@pytest.fixture(name="dummy_async_framer") +async def prepare_test_async_framer(entry, is_server): """Return framer object.""" - framer = Framer(entry, CommParams(), is_server, [0, 1]) # type: ignore[abstract] + decoder = (ServerDecoder if is_server else ClientDecoder)() + framer = AsyncFramer(entry, CommParams(), is_server, decoder, [0, 1]) # type: ignore[abstract] framer.send = mock.Mock() # type: ignore[method-assign] - if entry == FramerType.RTU: - func_table = (ServerDecoder if is_server else ClientDecoder)().lookup # type: ignore[attr-defined] - for key, ent in func_table.items(): - fix_len = getattr(ent, "_rtu_frame_size", 0) - cnt_pos = getattr(ent, "_rtu_byte_count_pos", 0) - framer.handle.set_fc_calc(key, fix_len, cnt_pos) + #if entry == FramerType.RTU: + #func_table = decoder.lookup # type: ignore[attr-defined] + #for key, ent in func_table.items(): + # fix_len = getattr(ent, "_rtu_frame_size", 0) + # cnt_pos = getattr(ent, "_rtu_byte_count_pos", 0) + # framer.handle.set_fc_calc(key, fix_len, cnt_pos) return framer diff --git a/test/framers/test_ascii.py b/test/framers/test_ascii.py deleted file mode 100644 index d3b4ef74c..000000000 --- a/test/framers/test_ascii.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Test framer.""" -import pytest - -from pymodbus.framer.ascii import FramerAscii - - -class TestFramerAscii: - """Test module.""" - - @staticmethod - @pytest.fixture(name="frame") - def prepare_frame(): - """Return message object.""" - return FramerAscii() - - - @pytest.mark.parametrize( - ("packet", "used_len", "res_id", "res"), - [ - (b':010100010001FC\r\n', 17, 1, b'\x01\x00\x01\x00\x01'), - (b':00010001000AF4\r\n', 17, 0, b'\x01\x00\x01\x00\x0a'), - (b':01010001000AF3\r\n', 17, 1, b'\x01\x00\x01\x00\x0a'), - (b':61620001000A32\r\n', 17, 97, b'\x62\x00\x01\x00\x0a'), - (b':01270001000ACD\r\n', 17, 1, b'\x27\x00\x01\x00\x0a'), - (b':010100', 0, 0, b''), # short frame - (b':00010001000AF4', 0, 0, b''), - (b'abc:00010001000AF4', 3, 0, b''), # garble before frame - (b'abc00010001000AF4', 17, 0, b''), # only garble - (b':01010001000A00\r\n', 17, 0, b''), - ], - ) - def test_decode(self, frame, packet, used_len, res_id, res): - """Test decode.""" - res_len, tid, dev_id, data = frame.decode(packet) - assert res_len == used_len - assert data == res - assert tid == res_id - assert dev_id == res_id - - @pytest.mark.parametrize( - ("data", "dev_id", "res_msg"), - [ - (b'\x01\x05\x04\x00\x17', 1, b':010105040017DF\r\n'), - (b'\x03\x07\x06\x00\x73', 2, b':0203070600737D\r\n'), - (b'\x08\x00\x01', 3, b':03080001F7\r\n'), - (b'\x84\x01', 2, b':02840179\r\n'), - ], - ) - def test_roundtrip(self, frame, data, dev_id, res_msg): - """Test encode.""" - msg = frame.encode(data, dev_id, 0) - res_len, _, res_id, res_data = frame.decode(msg) - assert data == res_data - assert dev_id == res_id - assert res_len == len(res_msg) diff --git a/test/framers/test_asyncframer.py b/test/framers/test_asyncframer.py new file mode 100644 index 000000000..b05663e40 --- /dev/null +++ b/test/framers/test_asyncframer.py @@ -0,0 +1,391 @@ +"""Test framer.""" + +from unittest import mock + +import pytest + +from pymodbus.factory import ClientDecoder +from pymodbus.framer import FramerType +from pymodbus.framer.ascii import FramerAscii +from pymodbus.framer.rtu import FramerRTU +from pymodbus.framer.socket import FramerSocket +from pymodbus.framer.tls import FramerTLS + + +class TestFramer: + """Test module.""" + + @pytest.mark.parametrize(("entry"), list(FramerType)) + async def test_framer_init(self, dummy_async_framer): + """Test framer type.""" + assert dummy_async_framer.handle + + @pytest.mark.parametrize(("data", "res_len", "cx", "rc"), [ + (b'12345', 5, 1, [(5, b'12345')]), # full frame + (b'12345', 0, 0, [(0, b'')]), # not full frame, need more data + (b'12345', 5, 0, [(5, b'')]), # faulty frame, skipped + (b'1234512345', 10, 2, [(5, b'12345'), (5, b'12345')]), # 2 full frames + (b'12345678', 5, 1, [(5, b'12345'), (0, b'')]), # full frame, not full frame + (b'67812345', 8, 1, [(8, b'12345')]), # garble first, full frame next + (b'12345678', 5, 0, [(5, b'')]), # garble first, not full frame + (b'12345678', 8, 0, [(8, b'')]), # garble first, faulty frame + ]) + async def test_framer_callback(self, dummy_async_framer, data, res_len, cx, rc): + """Test framer type.""" + dummy_async_framer.callback_request_response = mock.Mock() + dummy_async_framer.handle.decode = mock.MagicMock(side_effect=iter(rc)) + assert dummy_async_framer.callback_data(data) == res_len + assert dummy_async_framer.callback_request_response.call_count == cx + if cx: + dummy_async_framer.callback_request_response.assert_called_with(b'12345', 0, 0) + else: + dummy_async_framer.callback_request_response.assert_not_called() + + @pytest.mark.parametrize(("data", "res_len", "rc"), [ + (b'12345', 5, [(5, b'12345'), (0, b'')]), # full frame, wrong dev_id + ]) + async def test_framer_callback_wrong_id(self, dummy_async_framer, data, res_len, rc): + """Test framer type.""" + dummy_async_framer.callback_request_response = mock.Mock() + dummy_async_framer.handle.decode = mock.MagicMock(side_effect=iter(rc)) + dummy_async_framer.broadcast = False + assert dummy_async_framer.callback_data(data) == res_len + # dummy_async_framer.callback_request_response.assert_not_called() + + async def test_framer_build_send(self, dummy_async_framer): + """Test framer type.""" + dummy_async_framer.handle.encode = mock.MagicMock(return_value=(b'decode')) + dummy_async_framer.build_send(b'decode', 1, 0) + dummy_async_framer.handle.encode.assert_called_once() + dummy_async_framer.send.assert_called_once() + dummy_async_framer.send.assert_called_with(b'decode', None) + + @pytest.mark.parametrize( + ("data", "res_len", "res_id", "res_tid", "res_data"), [ + (b'\x00\x01', 0, 0, 0, b''), + (b'\x01\x02\x03', 3, 1, 2, b'\x03'), + (b'\x04\x05\x06\x07\x08\x09\x00\x01\x02\x03', 10, 4, 5, b'\x06\x07\x08\x09\x00\x01\x02\x03'), + ]) + async def xtest_framer_decode(self, dummy_async_framer, data, res_id, res_tid, res_len, res_data): + """Test decode method in all types.""" + t_len, t_id, t_tid, t_data = dummy_async_framer.handle.decode(data) + assert res_len == t_len + assert res_id == t_id + assert res_tid == t_tid + assert res_data == t_data + + @pytest.mark.parametrize( + ("data", "dev_id", "tr_id", "res_data"), [ + (b'\x01\x02', 5, 6, b'\x05\x06\x01\x02'), + (b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09', 17, 25, b'\x11\x19\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09'), + ]) + async def xtest_framer_encode(self, dummy_async_framer, data, dev_id, tr_id, res_data): + """Test decode method in all types.""" + t_data = dummy_async_framer.handle.encode(data, dev_id, tr_id) + assert res_data == t_data + + @pytest.mark.parametrize( + ("func", "test_compare", "expect"), + [(FramerAscii.check_LRC, 0x1c, True), + (FramerAscii.check_LRC, 0x0c, False), + (FramerAscii.compute_LRC, None, 0x1c), + (FramerRTU.check_CRC, 0xE2DB, True), + (FramerRTU.check_CRC, 0xDBE2, False), + (FramerRTU.compute_CRC, None, 0xE2DB), + ] + ) + def test_LRC_CRC(self, func, test_compare, expect): + """Test check_LRC.""" + data = b'\x12\x34\x23\x45\x34\x56\x45\x67' + assert expect == func(data, test_compare) if test_compare else func(data) + + def test_roundtrip_LRC(self): + """Test combined compute/check LRC.""" + data = b'\x12\x34\x23\x45\x34\x56\x45\x67' + assert FramerAscii.compute_LRC(data) == 0x1c + assert FramerAscii.check_LRC(data, 0x1C) + + def test_crc16_table(self): + """Test the crc16 table is prefilled.""" + assert len(FramerRTU.crc16_table) == 256 + assert isinstance(FramerRTU.crc16_table[0], int) + assert isinstance(FramerRTU.crc16_table[255], int) + + def test_roundtrip_CRC(self): + """Test combined compute/check CRC.""" + data = b'\x12\x34\x23\x45\x34\x56\x45\x67' + assert FramerRTU.compute_CRC(data) == 0xE2DB + assert FramerRTU.check_CRC(data, 0xE2DB) + + + +class TestFramerType: + """Test classes.""" + + @pytest.mark.parametrize( + ("frame", "frame_expected"), + [ + (FramerAscii, [ + b':0003007C00027F\r\n', + b':000304008D008EDE\r\n', + b':0083027B\r\n', + b':1103007C00026E\r\n', + b':110304008D008ECD\r\n', + b':1183026A\r\n', + b':FF03007C000280\r\n', + b':FF0304008D008EDF\r\n', + b':FF83027C\r\n', + b':0003007C00027F\r\n', + b':000304008D008EDE\r\n', + b':0083027B\r\n', + b':1103007C00026E\r\n', + b':110304008D008ECD\r\n', + b':1183026A\r\n', + b':FF03007C000280\r\n', + b':FF0304008D008EDF\r\n', + b':FF83027C\r\n', + b':0003007C00027F\r\n', + b':000304008D008EDE\r\n', + b':0083027B\r\n', + b':1103007C00026E\r\n', + b':110304008D008ECD\r\n', + b':1183026A\r\n', + b':FF03007C000280\r\n', + b':FF0304008D008EDF\r\n', + b':FF83027C\r\n', + ]), + (FramerRTU, [ + b'\x00\x03\x00\x7c\x00\x02\x04\x02', + b'\x00\x03\x04\x00\x8d\x00\x8e\xfa\xbc', + b'\x00\x83\x02\x91\x31', + b'\x11\x03\x00\x7c\x00\x02\x07\x43', + b'\x11\x03\x04\x00\x8d\x00\x8e\xfb\xbd', + b'\x11\x83\x02\xc1\x34', + b'\xff\x03\x00\x7c\x00\x02\x10\x0d', + b'\xff\x03\x04\x00\x8d\x00\x8e\xf5\xb3', + b'\xff\x83\x02\xa1\x01', + b'\x00\x03\x00\x7c\x00\x02\x04\x02', + b'\x00\x03\x04\x00\x8d\x00\x8e\xfa\xbc', + b'\x00\x83\x02\x91\x31', + b'\x11\x03\x00\x7c\x00\x02\x07\x43', + b'\x11\x03\x04\x00\x8d\x00\x8e\xfb\xbd', + b'\x11\x83\x02\xc1\x34', + b'\xff\x03\x00\x7c\x00\x02\x10\x0d', + b'\xff\x03\x04\x00\x8d\x00\x8e\xf5\xb3', + b'\xff\x83\x02\xa1\x01', + b'\x00\x03\x00\x7c\x00\x02\x04\x02', + b'\x00\x03\x04\x00\x8d\x00\x8e\xfa\xbc', + b'\x00\x83\x02\x91\x31', + b'\x11\x03\x00\x7c\x00\x02\x07\x43', + b'\x11\x03\x04\x00\x8d\x00\x8e\xfb\xbd', + b'\x11\x83\x02\xc1\x34', + b'\xff\x03\x00\x7c\x00\x02\x10\x0d', + b'\xff\x03\x04\x00\x8d\x00\x8e\xf5\xb3', + b'\xff\x83\x02\xa1\x01', + ]), + (FramerSocket, [ + b'\x00\x00\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', + b'\x00\x00\x00\x00\x00\x07\x00\x03\x04\x00\x8d\x00\x8e', + b'\x00\x00\x00\x00\x00\x03\x00\x83\x02', + b'\x00\x00\x00\x00\x00\x06\x11\x03\x00\x7c\x00\x02', + b'\x00\x00\x00\x00\x00\x07\x11\x03\x04\x00\x8d\x00\x8e', + b'\x00\x00\x00\x00\x00\x03\x11\x83\x02', + b'\x00\x00\x00\x00\x00\x06\xff\x03\x00\x7c\x00\x02', + b'\x00\x00\x00\x00\x00\x07\xff\x03\x04\x00\x8d\x00\x8e', + b'\x00\x00\x00\x00\x00\x03\xff\x83\x02', + b'\x0c\x05\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', + b'\x0c\x05\x00\x00\x00\x07\x00\x03\x04\x00\x8d\x00\x8e', + b'\x0c\x05\x00\x00\x00\x03\x00\x83\x02', + b'\x0c\x05\x00\x00\x00\x06\x11\x03\x00\x7c\x00\x02', + b'\x0c\x05\x00\x00\x00\x07\x11\x03\x04\x00\x8d\x00\x8e', + b'\x0c\x05\x00\x00\x00\x03\x11\x83\x02', + b'\x0c\x05\x00\x00\x00\x06\xff\x03\x00\x7c\x00\x02', + b'\x0c\x05\x00\x00\x00\x07\xff\x03\x04\x00\x8d\x00\x8e', + b'\x0c\x05\x00\x00\x00\x03\xff\x83\x02', + ]), + (FramerTLS, [ + b'\x03\x00\x7c\x00\x02', + b'\x03\x04\x00\x8d\x00\x8e', + b'\x83\x02', + ]), + ] + ) + @pytest.mark.parametrize( + ("inx1", "data"), + [ + (0, b"\x03\x00\x7c\x00\x02",), # Request + (1, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (2, b'\x83\x02',), # Exception + ] + ) + @pytest.mark.parametrize( + ("inx2", "dev_id"), + [ + (0, 0), + (3, 17), + (6, 255), + ] + ) + @pytest.mark.parametrize( + ("inx3", "tr_id"), + [ + (0, 0), + (9, 3077), + ] + ) + def test_encode_type(self, frame, frame_expected, data, dev_id, tr_id, inx1, inx2, inx3): + """Test encode method.""" + if frame == FramerTLS and dev_id + tr_id: + return + frame_obj = frame(ClientDecoder(), [0]) + expected = frame_expected[inx1 + inx2 + inx3] + encoded_data = frame_obj.encode(data, dev_id, tr_id) + assert encoded_data == expected + + @pytest.mark.parametrize( + ("entry", "is_server", "data", "dev_id", "tr_id", "expected"), + [ + (FramerType.ASCII, True, b':0003007C00027F\r\n', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.ASCII, False, b':000304008D008EDE\r\n', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.ASCII, False, b':0083027B\r\n', 0, 0, b'\x83\x02',), # Exception + (FramerType.ASCII, True, b':1103007C00026E\r\n', 17, 17, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.ASCII, False, b':110304008D008ECD\r\n', 17, 17, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.ASCII, False, b':1183026A\r\n', 17, 17, b'\x83\x02',), # Exception + (FramerType.ASCII, True, b':FF03007C000280\r\n', 255, 255, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.ASCII, False, b':FF0304008D008EDF\r\n', 255, 255, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.ASCII, False, b':FF83027C\r\n', 255, 255, b'\x83\x02',), # Exception + (FramerType.RTU, True, b'\x00\x03\x00\x7c\x00\x02\x04\x02', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.RTU, False, b'\x00\x03\x04\x00\x8d\x00\x8e\xfa\xbc', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.RTU, False, b'\x00\x83\x02\x91\x31', 0, 0, b'\x83\x02',), # Exception + (FramerType.RTU, True, b'\x11\x03\x00\x7c\x00\x02\x07\x43', 17, 17, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.RTU, False, b'\x11\x03\x04\x00\x8d\x00\x8e\xfb\xbd', 17, 17, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.RTU, False, b'\x11\x83\x02\xc1\x34', 17, 17, b'\x83\x02',), # Exception + (FramerType.RTU, True, b'\xff\x03\x00|\x00\x02\x10\x0d', 255, 255, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.RTU, False, b'\xff\x03\x04\x00\x8d\x00\x8e\xf5\xb3', 255, 255, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.RTU, False, b'\xff\x83\x02\xa1\x01', 255, 255, b'\x83\x02',), # Exception + (FramerType.SOCKET, True, b'\x00\x00\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x07\x00\x03\x04\x00\x8d\x00\x8e', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x03\x00\x83\x02', 0, 0, b'\x83\x02',), # Exception + (FramerType.SOCKET, True, b'\x00\x00\x00\x00\x00\x06\x11\x03\x00\x7c\x00\x02', 17, 0, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x07\x11\x03\x04\x00\x8d\x00\x8e', 17, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x03\x11\x83\x02', 17, 0, b'\x83\x02',), # Exception + (FramerType.SOCKET, True, b'\x00\x00\x00\x00\x00\x06\xff\x03\x00\x7c\x00\x02', 255, 0, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x07\xff\x03\x04\x00\x8d\x00\x8e', 255, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x03\xff\x83\x02', 255, 0, b'\x83\x02',), # Exception + (FramerType.SOCKET, True, b'\x0c\x05\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', 0, 3077, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x07\x00\x03\x04\x00\x8d\x00\x8e', 0, 3077, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x03\x00\x83\x02', 0, 3077, b'\x83\x02',), # Exception + (FramerType.SOCKET, True, b'\x0c\x05\x00\x00\x00\x06\x11\x03\x00\x7c\x00\x02', 17, 3077, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x07\x11\x03\x04\x00\x8d\x00\x8e', 17, 3077, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x03\x11\x83\x02', 17, 3077, b'\x83\x02',), # Exception + (FramerType.SOCKET, True, b'\x0c\x05\x00\x00\x00\x06\xff\x03\x00\x7c\x00\x02', 255, 3077, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x07\xff\x03\x04\x00\x8d\x00\x8e', 255, 3077, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x03\xff\x83\x02', 255, 3077, b'\x83\x02',), # Exception + (FramerType.TLS, True, b'\x03\x00\x7c\x00\x02', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request + (FramerType.TLS, False, b'\x03\x04\x00\x8d\x00\x8e', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response + (FramerType.TLS, False, b'\x83\x02', 0, 0, b'\x83\x02',), # Exception + ] + ) + @pytest.mark.parametrize( + ("split"), + [ + "no", + "half", + "single", + ] + ) + async def test_decode_type(self, entry, dummy_async_framer, data, dev_id, tr_id, expected, split): + """Test encode method.""" + if entry == FramerType.TLS and split != "no": + return + if entry == FramerType.RTU: + return + dummy_async_framer.callback_request_response = mock.MagicMock() + if split == "no": + used_len = dummy_async_framer.callback_data(data) + elif split == "half": + split_len = int(len(data) / 2) + assert not dummy_async_framer.callback_data(data[0:split_len]) + dummy_async_framer.callback_request_response.assert_not_called() + used_len = dummy_async_framer.callback_data(data) + else: + last = len(data) + for i in range(0, last -1): + assert not dummy_async_framer.callback_data(data[0:i+1]) + dummy_async_framer.callback_request_response.assert_not_called() + used_len = dummy_async_framer.callback_data(data) + assert used_len == len(data) + dummy_async_framer.callback_request_response.assert_called_with(expected, dev_id, tr_id) + + @pytest.mark.parametrize( + ("entry", "data", "exp"), + [ + (FramerType.ASCII, b':0003007C00017F\r\n', [ # bad crc + (17, b''), + ]), + (FramerType.ASCII, b':0003007C00027F\r\n:0003007C00027F\r\n', [ # double good crc + (17, b'\x03\x00\x7c\x00\x02'), + (17, b'\x03\x00\x7c\x00\x02'), + ]), + (FramerType.ASCII, b':0003007C00017F\r\n:0003007C00027F\r\n', [ # bad crc + good CRC + (34, b'\x03\x00\x7c\x00\x02'), + ]), + (FramerType.ASCII, b'abc:0003007C00027F\r\n', [ # garble in front + (20, b'\x03\x00\x7c\x00\x02'), + ]), + (FramerType.ASCII, b':0003007C00017F\r\nabc', [ # bad crc, garble after + (17, b''), + ]), + (FramerType.ASCII, b':0003007C00017F\r\nabcdefghijkl', [ # bad crc, garble after + (29, b''), + ]), + (FramerType.ASCII, b':0003007C00027F\r\nabc', [ # good crc, garble after + (17, b'\x03\x00\x7c\x00\x02'), + ]), + (FramerType.ASCII, b':0003007C00017F\r\n:0003', [ # bad crc, part second framer + (17, b''), + ]), + (FramerType.SOCKET, b'\x00\x00\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02\x00\x00\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', [ # double good crc + (12, b"\x03\x00\x7c\x00\x02"), + (12, b"\x03\x00\x7c\x00\x02"), + ]), + # (FramerType.RTU, b'\x00\x83\x02\x91\x21', [ # bad crc + # (5, b''), + #]), + #(FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31', [ # dummy char in stream, bad crc + # (5, b''), + #]), + # (FramerType.RTU, b'\x00\x83\x02\x91\x21\x00\x83\x02\x91\x31', [ # bad crc + good CRC + # (10, b'\x83\x02'), + #]), + #(FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31\x00\x83\x02\x91\x31', [ # dummy char in stream, bad crc + good CRC + # (11, b''), + #]), + + # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # garble in front + # (FramerType.ASCII, b'abc:0003007C00027F\r\n', [ # garble in front + # (20, b'\x03\x00\x7c\x00\x02'), + # ]), + + # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # garble after + # (FramerType.ASCII, b':0003007C00017F\r\nabc', [ # bad crc, garble after + # (17, b''), + # ]), + # (FramerType.ASCII, b':0003007C00017F\r\nabcdefghijkl', [ # bad crc, garble after + # (29, b''), + # ]), + # (FramerType.ASCII, b':0003007C00027F\r\nabc', [ # good crc, garble after + # (17, b'\x03\x00\x7c\x00\x02'), + # ]), + # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # part second framer + # (FramerType.ASCII, b':0003007C00017F\r\n:0003', [ # bad crc, part second framer + # (17, b''), + # ]), + ] + ) + async def test_decode_complicated(self, dummy_async_framer, data, exp): + """Test encode method.""" + for ent in exp: + used_len, res_data = dummy_async_framer.handle.decode(data) + assert used_len == ent[0] + assert res_data == ent[1] diff --git a/test/framers/test_framer.py b/test/framers/test_framer.py index 2ba0020ca..7f11faf12 100644 --- a/test/framers/test_framer.py +++ b/test/framers/test_framer.py @@ -1,9 +1,9 @@ """Test framer.""" -from unittest import mock import pytest +from pymodbus.factory import ClientDecoder from pymodbus.framer import FramerType from pymodbus.framer.ascii import FramerAscii from pymodbus.framer.rtu import FramerRTU @@ -15,73 +15,10 @@ class TestFramer: """Test module.""" @pytest.mark.parametrize(("entry"), list(FramerType)) - async def test_framer_init(self, dummy_framer): + async def test_framer_init(self, test_framer): """Test framer type.""" - assert dummy_framer.handle - - @pytest.mark.parametrize(("data", "res_len", "cx", "rc"), [ - (b'12345', 5, 1, [(5, 0, 0, b'12345')]), # full frame - (b'12345', 0, 0, [(0, 0, 0, b'')]), # not full frame, need more data - (b'12345', 5, 0, [(5, 0, 0, b'')]), # faulty frame, skipped - (b'1234512345', 10, 2, [(5, 0, 0, b'12345'), (5, 0, 0, b'12345')]), # 2 full frames - (b'12345678', 5, 1, [(5, 0, 0, b'12345'), (0, 0, 0, b'')]), # full frame, not full frame - (b'67812345', 8, 1, [(8, 0, 0, b'12345')]), # garble first, full frame next - (b'12345678', 5, 0, [(5, 0, 0, b'')]), # garble first, not full frame - (b'12345678', 8, 0, [(8, 0, 0, b'')]), # garble first, faulty frame - ]) - async def test_framer_callback(self, dummy_framer, data, res_len, cx, rc): - """Test framer type.""" - dummy_framer.callback_request_response = mock.Mock() - dummy_framer.handle.decode = mock.MagicMock(side_effect=iter(rc)) - assert dummy_framer.callback_data(data) == res_len - assert dummy_framer.callback_request_response.call_count == cx - if cx: - dummy_framer.callback_request_response.assert_called_with(b'12345', 0, 0) - else: - dummy_framer.callback_request_response.assert_not_called() - - @pytest.mark.parametrize(("data", "res_len", "rc"), [ - (b'12345', 5, [(5, 0, 17, b'12345'), (0, 0, 0, b'')]), # full frame, wrong dev_id - ]) - async def test_framer_callback_wrong_id(self, dummy_framer, data, res_len, rc): - """Test framer type.""" - dummy_framer.callback_request_response = mock.Mock() - dummy_framer.handle.decode = mock.MagicMock(side_effect=iter(rc)) - dummy_framer.broadcast = False - assert dummy_framer.callback_data(data) == res_len - dummy_framer.callback_request_response.assert_not_called() - - async def test_framer_build_send(self, dummy_framer): - """Test framer type.""" - dummy_framer.handle.encode = mock.MagicMock(return_value=(b'decode')) - dummy_framer.build_send(b'decode', 1, 0) - dummy_framer.handle.encode.assert_called_once() - dummy_framer.send.assert_called_once() - dummy_framer.send.assert_called_with(b'decode', None) - - @pytest.mark.parametrize( - ("data", "res_len", "res_id", "res_tid", "res_data"), [ - (b'\x00\x01', 0, 0, 0, b''), - (b'\x01\x02\x03', 3, 1, 2, b'\x03'), - (b'\x04\x05\x06\x07\x08\x09\x00\x01\x02\x03', 10, 4, 5, b'\x06\x07\x08\x09\x00\x01\x02\x03'), - ]) - async def test_framer_decode(self, dummy_framer, data, res_id, res_tid, res_len, res_data): - """Test decode method in all types.""" - t_len, t_id, t_tid, t_data = dummy_framer.handle.decode(data) - assert res_len == t_len - assert res_id == t_id - assert res_tid == t_tid - assert res_data == t_data - - @pytest.mark.parametrize( - ("data", "dev_id", "tr_id", "res_data"), [ - (b'\x01\x02', 5, 6, b'\x05\x06\x01\x02'), - (b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09', 17, 25, b'\x11\x19\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09'), - ]) - async def test_framer_encode(self, dummy_framer, data, dev_id, tr_id, res_data): - """Test decode method in all types.""" - t_data = dummy_framer.handle.encode(data, dev_id, tr_id) - assert res_data == t_data + test_framer.incomming_dev_id = 1 + assert test_framer.incomming_dev_id @pytest.mark.parametrize( ("func", "test_compare", "expect"), @@ -117,7 +54,6 @@ def test_roundtrip_CRC(self): assert FramerRTU.check_CRC(data, 0xE2DB) - class TestFramerType: """Test classes.""" @@ -236,7 +172,7 @@ def test_encode_type(self, frame, frame_expected, data, dev_id, tr_id, inx1, inx """Test encode method.""" if frame == FramerTLS and dev_id + tr_id: return - frame_obj = frame() + frame_obj = frame(ClientDecoder(), [0]) expected = frame_expected[inx1 + inx2 + inx3] encoded_data = frame_obj.encode(data, dev_id, tr_id) assert encoded_data == expected @@ -293,28 +229,35 @@ def test_encode_type(self, frame, frame_expected, data, dev_id, tr_id, inx1, inx "single", ] ) - async def test_decode_type(self, entry, dummy_framer, data, dev_id, tr_id, expected, split): + async def test_decode_type(self, entry, test_framer, data, dev_id, tr_id, expected, split): """Test encode method.""" if entry == FramerType.TLS and split != "no": return if entry == FramerType.RTU: return - dummy_framer.callback_request_response = mock.MagicMock() if split == "no": - used_len = dummy_framer.callback_data(data) + used_len, res_data = test_framer.decode(data) elif split == "half": split_len = int(len(data) / 2) - assert not dummy_framer.callback_data(data[0:split_len]) - dummy_framer.callback_request_response.assert_not_called() - used_len = dummy_framer.callback_data(data) + used_len, res_data = test_framer.decode(data[0:split_len]) + assert not used_len + assert not res_data + assert not test_framer.incoming_dev_id + assert not test_framer.incoming_tid + used_len, res_data = test_framer.decode(data) else: last = len(data) for i in range(0, last -1): - assert not dummy_framer.callback_data(data[0:i+1]) - dummy_framer.callback_request_response.assert_not_called() - used_len = dummy_framer.callback_data(data) + used_len, res_data = test_framer.decode(data[0:i+1]) + assert not used_len + assert not res_data + assert not test_framer.incoming_dev_id + assert not test_framer.incoming_tid + used_len, res_data = test_framer.decode(data) assert used_len == len(data) - dummy_framer.callback_request_response.assert_called_with(expected, dev_id, tr_id) + assert res_data == expected + assert dev_id == test_framer.incoming_dev_id + assert tr_id == test_framer.incoming_tid @pytest.mark.parametrize( ("entry", "data", "exp"), @@ -348,43 +291,51 @@ async def test_decode_type(self, entry, dummy_framer, data, dev_id, tr_id, expec (12, b"\x03\x00\x7c\x00\x02"), (12, b"\x03\x00\x7c\x00\x02"), ]), - # (FramerType.RTU, b'\x00\x83\x02\x91\x21', [ # bad crc - # (5, b''), - #]), - #(FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31', [ # dummy char in stream, bad crc - # (5, b''), - #]), - # (FramerType.RTU, b'\x00\x83\x02\x91\x21\x00\x83\x02\x91\x31', [ # bad crc + good CRC - # (10, b'\x83\x02'), - #]), - #(FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31\x00\x83\x02\x91\x31', [ # dummy char in stream, bad crc + good CRC - # (11, b''), - #]), - - # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # garble in front - # (FramerType.ASCII, b'abc:0003007C00027F\r\n', [ # garble in front - # (20, b'\x03\x00\x7c\x00\x02'), - # ]), - - # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # garble after - # (FramerType.ASCII, b':0003007C00017F\r\nabc', [ # bad crc, garble after - # (17, b''), - # ]), - # (FramerType.ASCII, b':0003007C00017F\r\nabcdefghijkl', [ # bad crc, garble after - # (29, b''), - # ]), - # (FramerType.ASCII, b':0003007C00027F\r\nabc', [ # good crc, garble after - # (17, b'\x03\x00\x7c\x00\x02'), - # ]), - # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # part second framer - # (FramerType.ASCII, b':0003007C00017F\r\n:0003', [ # bad crc, part second framer - # (17, b''), - # ]), + (FramerType.RTU, b'\x00\x83\x02\x91\x21', [ # bad crc + (2, b''), + ]), + (FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31', [ # dummy char in stream, bad crc + (3, b''), + ]), + (FramerType.RTU, b'\x00\x83\x02\x91\x21\x00\x83\x02\x91\x31', [ # bad crc + good CRC + (10, b'\x83\x02'), + ]), + (FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31\x00\x83\x02\x91\x31', [ # dummy char in stream, bad crc + good CRC + (11, b'\x83\x02'), + ]), ] ) - async def test_decode_complicated(self, dummy_framer, data, exp): + async def test_decode_complicated(self, test_framer, data, exp): """Test encode method.""" for ent in exp: - used_len, _, _, res_data = dummy_framer.handle.decode(data) + used_len, res_data = test_framer.decode(data) assert used_len == ent[0] assert res_data == ent[1] + + @pytest.mark.parametrize( + ("entry", "data", "dev_id", "res_msg"), + [ + (FramerType.ASCII, b'\x01\x05\x04\x00\x17', 1, b':010105040017DF\r\n'), + (FramerType.ASCII, b'\x03\x07\x06\x00\x73', 2, b':0203070600737D\r\n'), + (FramerType.ASCII,b'\x08\x00\x01', 3, b':03080001F7\r\n'), + (FramerType.ASCII,b'\x84\x01', 2, b':02840179\r\n'), + (FramerType.RTU, b'\x01\x01\x00', 2, b'\x02\x01\x01\x00\x51\xcc'), + (FramerType.RTU, b'\x03\x06\xAE\x41\x56\x52\x43\x40', 17, b'\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD'), + (FramerType.RTU, b'\x01\x03\x01\x00\x0a', 1, b'\x01\x01\x03\x01\x00\x0a\xed\x89'), + (FramerType.SOCKET, b'\x01\x05\x04\x00\x17', 31, b'\x00\x05\x00\x00\x00\x06\x07\x01\x05\x04\x00\x17'), + (FramerType.SOCKET, b'\x03\x07\x06\x00\x73', 32, b'\x00\x09\x00\x00\x00\x06\x02\x03\x07\x06\x00\x73'), + (FramerType.SOCKET, b'\x08\x00\x01', 33, b'\x00\x06\x00\x00\x00\x04\x03\x08\x00\x01'), + (FramerType.SOCKET, b'\x84\x01', 34, b'\x00\x08\x00\x00\x00\x03\x04\x84\x01'), + (FramerType.TLS, b'\x01\x05\x04\x00\x17', 0, b'\x01\x05\x04\x00\x17'), + (FramerType.TLS, b'\x03\x07\x06\x00\x73', 0, b'\x03\x07\x06\x00\x73'), + (FramerType.TLS, b'\x08\x00\x01', 0, b'\x08\x00\x01'), + (FramerType.TLS, b'\x84\x01', 0, b'\x84\x01'), + ], + ) + def test_roundtrip(self, test_framer, data, dev_id, res_msg): + """Test encode.""" + msg = test_framer.encode(data, dev_id, 0) + res_len, res_data = test_framer.decode(msg) + assert data == res_data + assert dev_id == test_framer.incoming_dev_id + assert res_len == len(res_msg) diff --git a/test/framers/server_multidrop_tbd.py b/test/framers/test_multidrop.py similarity index 99% rename from test/framers/server_multidrop_tbd.py rename to test/framers/test_multidrop.py index e2a21bd14..9461bb2ee 100644 --- a/test/framers/server_multidrop_tbd.py +++ b/test/framers/test_multidrop.py @@ -7,7 +7,7 @@ from pymodbus.server.async_io import ServerDecoder -class TestMultidrop: +class NotImplementedTestMultidrop: """Test that server works on a multidrop line.""" slaves = [2] diff --git a/test/framers/test_old_framers.py b/test/framers/test_old_framers.py deleted file mode 100644 index 8c85a832e..000000000 --- a/test/framers/test_old_framers.py +++ /dev/null @@ -1,476 +0,0 @@ -"""Test framers.""" -from unittest import mock - -import pytest - -from pymodbus import FramerType -from pymodbus.client.base import ModbusBaseClient -from pymodbus.exceptions import ModbusIOException -from pymodbus.factory import ClientDecoder -from pymodbus.framer import ( - ModbusAsciiFramer, - ModbusRtuFramer, - ModbusSocketFramer, - ModbusTlsFramer, -) -from pymodbus.pdu.bit_read_message import ReadCoilsRequest -from pymodbus.transport import CommParams, CommType -from pymodbus.utilities import ModbusTransactionState - - -BASE_PORT = 6600 - - -TEST_MESSAGE = b"\x00\x01\x00\x01\x00\n\xec\x1c" - - -class TestFramers: - """Test framers.""" - - slaves = [2, 17] - - @staticmethod - @pytest.fixture(name="rtu_framer") - def fixture_rtu_framer(): - """RTU framer.""" - return ModbusRtuFramer(ClientDecoder()) - - @staticmethod - @pytest.fixture(name="ascii_framer") - def fixture_ascii_framer(): - """Ascii framer.""" - return ModbusAsciiFramer(ClientDecoder()) - - - @pytest.mark.parametrize( - "framer", - [ - ModbusRtuFramer, - ModbusAsciiFramer, - ModbusSocketFramer, - ], -) - def test_framer_initialization(self, framer): - """Test framer initialization.""" - decoder = ClientDecoder() - framer = framer(decoder) - assert framer.client is None - assert framer._buffer == b"" # pylint: disable=protected-access - assert framer.decoder == decoder - if isinstance(framer, ModbusAsciiFramer): - assert not framer.dev_id - assert framer._hsize == 0x02 # pylint: disable=protected-access - assert framer._start == b":" # pylint: disable=protected-access - assert framer._end == b"\r\n" # pylint: disable=protected-access - elif isinstance(framer, ModbusRtuFramer): - assert not framer.dev_id - assert framer._hsize == 0x01 # pylint: disable=protected-access - else: - assert not framer.dev_id - assert framer._hsize == 0x07 # pylint: disable=protected-access - - - @pytest.mark.parametrize( - ("data", "expected"), - [ - (b"", 0), - (b"\x02\x01\x01\x00Q\xcc", 1), - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", 1), # valid frame - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAC", 0), # invalid frame CRC - ], - ) - def test_check_frame(self, rtu_framer, data, expected): - """Test check frame.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert count == expected - - - @pytest.mark.parametrize( - ("data", "res"), - [ - (b"", 0), - (b"abcd", 0), - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x12\x03", 1), # real case, frame size is 11 - ], - ) - def test_rtu_advance_framer(self, rtu_framer, data, res): - """Test rtu advance framer.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - rtu_framer.dev_id = 0 - rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert count == res - - - @pytest.mark.parametrize("data", [b"", b"abcd"]) - def test_rtu_reset_framer(self, rtu_framer, data): - """Test rtu reset framer.""" - rtu_framer._buffer = data # pylint: disable=protected-access - rtu_framer.resetFrame() - assert not rtu_framer.dev_id - - - @pytest.mark.parametrize( - ("data", "expected"), - [ - (b"", 0), - (b"\x11", 0), - (b"\x11\x03", 0), - (b"\x11\x03\x06", 0), - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49", 0), - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", 1), - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\xAB\xCD", 1), - ], - ) - def test_is_frame_ready(self, rtu_framer, data, expected): - """Test is frame ready.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert count == expected - - - @pytest.mark.parametrize( - "data", - [ - b"", - b"\x11", - b"\x11\x03", - b"\x11\x03\x06", - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x43", - ], - ) - def test_rtu_populate_fail(self, rtu_framer, data): - """Test rtu populate header fail.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert not count - callback(b'') - assert count - - @pytest.mark.parametrize( - ("data", "dev_id"), - [ - ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", 17, - ), - ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x11\x03", 17, - ), - ], - ) - def test_rtu_populate(self, rtu_framer, data, dev_id): - """Test rtu populate header.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - - rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert result.slave_id == dev_id - - - def test_get_frame(self, rtu_framer): - """Test get frame.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - data = b"\x02\x01\x01\x00Q\xcc" - rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert count - assert result.function_code.to_bytes(1,'big') + result.encode() == b"\x01\x01\x00" - - - def test_populate_result(self, rtu_framer): - """Test populate result.""" - rtu_framer.dev_id = 255 - result = mock.Mock() - rtu_framer.populateResult(result) - assert result.slave_id == 255 - - - @pytest.mark.parametrize( - ("data", "slaves", "cb_called"), - [ - (b"\x11", [17], 0), # not complete frame - (b"\x11\x03", [17], 0), # not complete frame - (b"\x11\x03\x06", [17], 0), # not complete frame - (b"\x11\x03\x06\xAE\x41\x56\x52\x43", [17], 0), # not complete frame - ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40", - [17], - 0, - ), # not complete frame - ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49", - [17], - 0, - ), # not complete frame - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAC", [17], 0), # bad crc - ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", - [17], - 1, - ), # good frame - ( - b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD", - [16], - 0, - ), # incorrect slave id - (b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x11\x03", [17], 1), - # good frame + part of next frame - ], - ) - def test_rtu_incoming_packet(self, rtu_framer, data, slaves, cb_called): - """Test rtu process incoming packet.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - rtu_framer.processIncomingPacket(data, callback, slaves) - assert count == cb_called - - - async def test_send_packet(self, rtu_framer): - """Test send packet.""" - message = TEST_MESSAGE - client = ModbusBaseClient( - FramerType.ASCII, - 3, - None, - comm_params=CommParams( - comm_type=CommType.TCP, - host="localhost", - port=BASE_PORT + 1, - ), - ) - client.state = ModbusTransactionState.TRANSACTION_COMPLETE - client.silent_interval = 1 - client.last_frame_end = 1 - client.ctx.comm_params.timeout_connect = 0.25 - client.idle_time = mock.Mock(return_value=1) - client.send = mock.Mock(return_value=len(message)) - rtu_framer.client = client - assert rtu_framer.sendPacket(message) == len(message) - client.state = ModbusTransactionState.PROCESSING_REPLY - assert rtu_framer.sendPacket(message) == len(message) - - - def test_recv_packet(self, rtu_framer): - """Test receive packet.""" - message = TEST_MESSAGE - client = mock.Mock() - client.recv.return_value = message - rtu_framer.client = client - assert rtu_framer.recvPacket(len(message)) == message - - def test_process(self, rtu_framer): - """Test process.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - data = TEST_MESSAGE - rtu_framer.processIncomingPacket(data, callback, self.slaves) - assert not count - callback(b'') - assert count - - @pytest.mark.parametrize(("slaves", "res"), [([16], 0), ([17], 1)]) - def test_validate__slave_id(self,rtu_framer, slaves, res): - """Test validate slave.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - data = b"\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD\x12\x03" - rtu_framer.processIncomingPacket(data, callback, slaves) - assert count == res - - @pytest.mark.parametrize("data", [b":010100010001FC\r\n", b""]) - def test_decode_ascii_data(self, ascii_framer, data): - """Test decode ascii.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - ascii_framer.processIncomingPacket(data, callback, [1]) - if result: - assert result.slave_id == 1 - assert result.function_code == 1 - else: - assert not result - - def test_recv_split_packet(self): - """Test receive packet.""" - response_ok = False - - def _handle_response(_reply): - """Handle response.""" - nonlocal response_ok - response_ok = True - - message = bytearray(b"\x00\x01\x00\x00\x00\x0b\x01\x03\x08\x00\xb5\x12\x2f\x37\x21\x00\x03") - for i in range(0, len(message)): - part1 = message[:i] - part2 = message[i:] - response_ok = False - framer = ModbusSocketFramer(ClientDecoder()) - if i: - framer.processIncomingPacket(part1, _handle_response, 0) - assert not response_ok, "Response should not be accepted" - framer.processIncomingPacket(part2, _handle_response, 0) - assert response_ok, "Response is valid, but not accepted" - - - def test_recv_socket_exception_packet(self): - """Test receive packet.""" - response_ok = False - - def _handle_response(_reply): - """Handle response.""" - nonlocal response_ok - response_ok = True - - message = bytearray(b"\x00\x02\x00\x00\x00\x03\x01\x84\x02") - response_ok = False - framer = ModbusSocketFramer(ClientDecoder()) - framer.processIncomingPacket(message, _handle_response, 0) - assert response_ok, "Response is valid, but not accepted" - - message = bytearray(b"\x00\x01\x00\x00\x00\x0b\x01\x03\x08\x00\xb5\x12\x2f\x37\x21\x00\x03") - response_ok = False - framer = ModbusSocketFramer(ClientDecoder()) - framer.processIncomingPacket(message, _handle_response, 0) - assert response_ok, "Response is valid, but not accepted" - - def test_recv_socket_exception_faulty(self): - """Test receive packet.""" - response_ok = False - - def _handle_response(_reply): - """Handle response.""" - nonlocal response_ok - response_ok = True - - message = bytearray(b"\x00\x02\x00\x00\x00\x02\x01\x84\x02") - response_ok = False - framer = ModbusSocketFramer(ClientDecoder()) - framer.processIncomingPacket(message, _handle_response, 0) - assert response_ok, "Response is valid, but not accepted" - - # ---- 100% coverage - @pytest.mark.parametrize( - ("framer", "message"), - [ - (ModbusAsciiFramer, b':01010001000AF3\r\n',), - (ModbusRtuFramer, b"\x01\x01\x00\x01\x00\n\xed\xcd",), - (ModbusSocketFramer, b'\x00\x00\x00\x00\x00\x06\x01\x01\x00\x01\x00\n',), - ] - ) - def test_build_packet(self, framer, message): - """Test build packet.""" - test_framer = framer(ClientDecoder()) - request = ReadCoilsRequest(1, 10) - assert test_framer.buildPacket(request) == message - - - @pytest.mark.parametrize( - ("framer", "message"), - [ - (ModbusAsciiFramer, b':01010001000AF3\r\n',), - (ModbusRtuFramer, b"\x01\x01\x03\x01\x00\n\xed\x89",), - (ModbusSocketFramer, b'\x00\x00\x00\x00\x00\x06\x01\x01\x00\x01\x00\n',), - ] - ) - @pytest.mark.parametrize(("slave"), [0x01, 0x02]) - def test_processincomingpacket_ok(self, framer, message, slave): - """Test processIncomingPacket.""" - test_framer = framer(ClientDecoder()) - test_framer.processIncomingPacket(message, mock.Mock(), slave) - - - @pytest.mark.parametrize( - ("framer", "message"), - [ - (ModbusAsciiFramer, b':01270001000ACD\r\n',), - (ModbusRtuFramer, b"\x01\x03\x03\x01\x00\n\x94\x49",), - (ModbusSocketFramer, b'\x00\x00\x00\x00\x00\x06\x01\x27\x00\x01\x00\n',), - (ModbusTlsFramer, b'\x54\x00\x7c\x00\x02',), - ] - ) - def test_processincomingpacket_not_ok(self, framer, message): - """Test processIncomingPacket.""" - test_framer = framer(ClientDecoder()) - with pytest.raises(ModbusIOException): - test_framer.processIncomingPacket(message, mock.Mock(), 0x01) - - @pytest.mark.parametrize( - ("framer", "message"), - [ - (ModbusAsciiFramer, b':61620001000AF4\r\n',), - (ModbusRtuFramer, b"\x61\x62\x00\x01\x00\n\xec\x1c",), - (ModbusSocketFramer, b'\x00\x00\x00\x00\x00\x06\x61\x62\x00\x01\x00\n',), - ] - ) - @pytest.mark.parametrize("expected", [{"fcode": 98, "slave": 97}]) - def test_decode_data(self, framer, message, expected): - """Test decode data.""" - test_framer = framer(ClientDecoder()) - decoded = test_framer.decode_data(b'') - assert decoded == {} - decoded = test_framer.decode_data(message) - assert decoded["fcode"] == expected["fcode"] - assert decoded["slave"] == expected["slave"] diff --git a/test/framers/test_rtu.py b/test/framers/test_rtu.py deleted file mode 100644 index 143a29d3d..000000000 --- a/test/framers/test_rtu.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Test framer.""" -import pytest - -from pymodbus.framer.rtu import FramerRTU - - -class TestFramerRTU: - """Test module.""" - - @staticmethod - @pytest.fixture(name="frame") - def prepare_frame(): - """Return message object.""" - return FramerRTU() - - @pytest.mark.skip - @pytest.mark.parametrize( - ("packet", "used_len", "res_id", "res"), - [ - (b':010100010001FC\r\n', 17, 1, b'\x01\x00\x01\x00\x01'), - (b':00010001000AF4\r\n', 17, 0, b'\x01\x00\x01\x00\x0a'), - (b':01010001000AF3\r\n', 17, 1, b'\x01\x00\x01\x00\x0a'), - (b':61620001000A32\r\n', 17, 97, b'\x62\x00\x01\x00\x0a'), - (b':01270001000ACD\r\n', 17, 1, b'\x27\x00\x01\x00\x0a'), - (b':010100', 0, 0, b''), # short frame - (b':00010001000AF4', 0, 0, b''), - (b'abc:00010001000AF4', 3, 0, b''), # garble before frame - (b'abc00010001000AF4', 17, 0, b''), # only garble - (b':01010001000A00\r\n', 17, 0, b''), - ], - ) - def test_decode(self, frame, packet, used_len, res_id, res): - """Test decode.""" - res_len, tid, dev_id, data = frame.decode(packet) - assert res_len == used_len - assert data == res - assert tid == res_id - assert dev_id == res_id - - @pytest.mark.parametrize( - ("data", "dev_id", "res_msg"), - [ - (b'\x01\x01\x00', 2, b'\x02\x01\x01\x00\x51\xcc'), - (b'\x03\x06\xAE\x41\x56\x52\x43\x40', 17, b'\x11\x03\x06\xAE\x41\x56\x52\x43\x40\x49\xAD'), - (b'\x01\x03\x01\x00\x0a', 1, b'\x01\x01\x03\x01\x00\x0a\xed\x89'), - ], - ) - def test_roundtrip(self, frame, data, dev_id, res_msg): - """Test encode.""" - # msg = frame.encode(data, dev_id, 0) - # res_len, _, res_id, res_data = frame.decode(msg) - # assert data == res_data - # assert dev_id == res_id - # assert res_len == len(res_msg) diff --git a/test/framers/test_socket.py b/test/framers/test_socket.py deleted file mode 100644 index 6c1b496fb..000000000 --- a/test/framers/test_socket.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Test framer.""" - -import pytest - -from pymodbus.framer.socket import FramerSocket - - -class TestFramerSocket: - """Test module.""" - - @staticmethod - @pytest.fixture(name="frame") - def prepare_frame(): - """Return message object.""" - return FramerSocket() - - - @pytest.mark.parametrize( - ("packet", "used_len", "res_id", "res_tid", "res"), - [ - (b"\x00\x09\x00\x00\x00\x05\x01\x03\x01\x14\xb5", 11, 1, 9, b'\x03\x01\x14\xb5'), - (b"\x00\x02\x00\x00\x00\x03\x07\x84\x02", 9, 7, 2, b'\x84\x02'), - (b"\x00\x02\x00", 0, 0, 0, b''), # very short frame - (b"\x00\x09\x00\x00\x00\x05\x01\x03\x01", 0, 0, 0, b''), # short frame - (b"\x00\x02\x00\x00\x00\x03\x07\x84", 0, 0, 0, b''), # short frame -1 byte - ], - ) - def test_decode(self, frame, packet, used_len, res_id, res_tid, res): - """Test decode.""" - res_len, tid, dev_id, data = frame.decode(packet) - assert res_len == used_len - assert res == data - assert res_tid == tid - assert dev_id == res_id - - - @pytest.mark.parametrize( - ("data", "dev_id", "tr_id", "res_msg"), - [ - (b'\x01\x05\x04\x00\x17', 7, 5, b'\x00\x05\x00\x00\x00\x06\x07\x01\x05\x04\x00\x17'), - (b'\x03\x07\x06\x00\x73', 2, 9, b'\x00\x09\x00\x00\x00\x06\x02\x03\x07\x06\x00\x73'), - (b'\x08\x00\x01', 3, 6, b'\x00\x06\x00\x00\x00\x04\x03\x08\x00\x01'), - (b'\x84\x01', 4, 8, b'\x00\x08\x00\x00\x00\x03\x04\x84\x01'), - ], - ) - def test_roundtrip(self, frame, data, dev_id, tr_id, res_msg): - """Test encode.""" - msg = frame.encode(data, dev_id, tr_id) - res_len, res_tid, res_id, res_data = frame.decode(msg) - assert data == res_data - assert dev_id == res_id - assert tr_id == res_tid - assert res_len == len(res_msg) diff --git a/test/framers/test_tbc_transaction.py b/test/framers/test_tbc_transaction.py deleted file mode 100755 index 66b74d592..000000000 --- a/test/framers/test_tbc_transaction.py +++ /dev/null @@ -1,690 +0,0 @@ -"""Test transaction.""" -from unittest import mock - -from pymodbus.exceptions import ( - ModbusIOException, -) -from pymodbus.factory import ServerDecoder -from pymodbus.pdu import ModbusRequest -from pymodbus.transaction import ( - ModbusAsciiFramer, - ModbusRtuFramer, - ModbusSocketFramer, - ModbusTlsFramer, - SyncModbusTransactionManager, -) - - -TEST_MESSAGE = b"\x7b\x01\x03\x00\x00\x00\x05\x85\xC9\x7d" - - -class TestTransaction: # pylint: disable=too-many-public-methods - """Unittest for the pymodbus.transaction module.""" - - client = None - decoder = None - _tcp = None - _tls = None - _rtu = None - _ascii = None - _manager = None - _tm = None - - # ----------------------------------------------------------------------- # - # Test Construction - # ----------------------------------------------------------------------- # - def setup_method(self): - """Set up the test environment.""" - self.client = None - self.decoder = ServerDecoder() - self._tcp = ModbusSocketFramer(decoder=self.decoder, client=None) - self._tls = ModbusTlsFramer(decoder=self.decoder, client=None) - self._rtu = ModbusRtuFramer(decoder=self.decoder, client=None) - self._ascii = ModbusAsciiFramer(decoder=self.decoder, client=None) - self._manager = SyncModbusTransactionManager(self.client, 3) - - # ----------------------------------------------------------------------- # - # Modbus transaction manager - # ----------------------------------------------------------------------- # - - def test_calculate_expected_response_length(self): - """Test calculate expected response length.""" - self._manager.client = mock.MagicMock() - self._manager.client.framer = mock.MagicMock() - self._manager._set_adu_size() # pylint: disable=protected-access - assert not self._manager._calculate_response_length( # pylint: disable=protected-access - 0 - ) - self._manager.base_adu_size = 10 - assert ( - self._manager._calculate_response_length(5) # pylint: disable=protected-access - == 15 - ) - - def test_calculate_exception_length(self): - """Test calculate exception length.""" - for framer, exception_length in ( - ("ascii", 11), - ("rtu", 5), - ("tcp", 9), - ("tls", 2), - ("dummy", None), - ): - self._manager.client = mock.MagicMock() - if framer == "ascii": - self._manager.client.framer = self._ascii - elif framer == "rtu": - self._manager.client.framer = self._rtu - elif framer == "tcp": - self._manager.client.framer = self._tcp - elif framer == "tls": - self._manager.client.framer = self._tls - else: - self._manager.client.framer = mock.MagicMock() - - self._manager._set_adu_size() # pylint: disable=protected-access - assert ( - self._manager._calculate_exception_length() # pylint: disable=protected-access - == exception_length - ) - - def test_execute(self): - """Test execute.""" - client = mock.MagicMock() - client.framer = self._ascii - client.framer._buffer = b"deadbeef" # pylint: disable=protected-access - client.framer.processIncomingPacket = mock.MagicMock() - client.framer.processIncomingPacket.return_value = None - client.framer.buildPacket = mock.MagicMock() - client.framer.buildPacket.return_value = b"deadbeef" - client.framer.sendPacket = mock.MagicMock() - client.framer.sendPacket.return_value = len(b"deadbeef") - client.framer.decode_data = mock.MagicMock() - client.framer.decode_data.return_value = { - "slave": 1, - "fcode": 222, - "length": 27, - } - request = mock.MagicMock() - request.get_response_pdu_size.return_value = 10 - request.slave_id = 1 - request.function_code = 222 - trans = SyncModbusTransactionManager(client, 3) - trans._recv = mock.MagicMock( # pylint: disable=protected-access - return_value=b"abcdef" - ) - assert trans.retries == 3 - - trans.getTransaction = mock.MagicMock() - trans.getTransaction.return_value = "response" - response = trans.execute(request) - assert response == "response" - # No response - trans._recv = mock.MagicMock( # pylint: disable=protected-access - return_value=b"abcdef" - ) - trans.transactions = {} - trans.getTransaction = mock.MagicMock() - trans.getTransaction.return_value = None - response = trans.execute(request) - assert isinstance(response, ModbusIOException) - - # No response with retries - trans._recv = mock.MagicMock( # pylint: disable=protected-access - side_effect=iter([b"", b"abcdef"]) - ) - response = trans.execute(request) - assert isinstance(response, ModbusIOException) - - # wrong handle_local_echo - trans._recv = mock.MagicMock( # pylint: disable=protected-access - side_effect=iter([b"abcdef", b"deadbe", b"123456"]) - ) - client.comm_params.handle_local_echo = True - assert trans.execute(request).message == "[Input/Output] Wrong local echo" - client.comm_params.handle_local_echo = False - - # retry on invalid response - trans._recv = mock.MagicMock( # pylint: disable=protected-access - side_effect=iter([b"", b"abcdef", b"deadbe", b"123456"]) - ) - response = trans.execute(request) - assert isinstance(response, ModbusIOException) - - # Unable to decode response - trans._recv = mock.MagicMock( # pylint: disable=protected-access - side_effect=ModbusIOException() - ) - client.framer.processIncomingPacket.side_effect = mock.MagicMock( - side_effect=ModbusIOException() - ) - assert isinstance(trans.execute(request), ModbusIOException) - - def test_transaction_manager_tid(self): - """Test the transaction manager TID.""" - for tid in range(1, self._manager.getNextTID() + 10): - assert tid + 1 == self._manager.getNextTID() - self._manager.reset() - assert self._manager.getNextTID() == 1 - - def test_get_transaction_manager_transaction(self): - """Test the getting a transaction from the transaction manager.""" - - class Request: # pylint: disable=too-few-public-methods - """Request.""" - - self._manager.reset() - handle = Request() - handle.transaction_id = ( # pylint: disable=attribute-defined-outside-init - self._manager.getNextTID() - ) - handle.message = b"testing" # pylint: disable=attribute-defined-outside-init - self._manager.addTransaction(handle) - result = self._manager.getTransaction(handle.transaction_id) - assert handle.message == result.message - - def test_delete_transaction_manager_transaction(self): - """Test deleting a transaction from the dict transaction manager.""" - - class Request: # pylint: disable=too-few-public-methods - """Request.""" - - self._manager.reset() - handle = Request() - handle.transaction_id = ( # pylint: disable=attribute-defined-outside-init - self._manager.getNextTID() - ) - handle.message = b"testing" # pylint: disable=attribute-defined-outside-init - - self._manager.addTransaction(handle) - self._manager.delTransaction(handle.transaction_id) - assert not self._manager.getTransaction(handle.transaction_id) - - # ----------------------------------------------------------------------- # - # TCP tests - # ----------------------------------------------------------------------- # - def test_tcp_framer_transaction_ready(self): - """Test a tcp frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg, callback, [1]) - self._tcp._buffer = msg # pylint: disable=protected-access - callback(b'') - - def test_tcp_framer_transaction_full(self): - """Test a full tcp frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg, callback, [0, 1]) - assert result.function_code.to_bytes(1,'big') + result.encode() == msg[7:] - - def test_tcp_framer_transaction_half(self): - """Test a half completed tcp frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg1 = b"\x00\x01\x12\x34\x00" - msg2 = b"\x06\xff\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg1, callback, [0, 1]) - assert not result - self._tcp.processIncomingPacket(msg2, callback, [0, 1]) - assert result - assert result.function_code.to_bytes(1,'big') + result.encode() == msg2[2:] - - def test_tcp_framer_transaction_half2(self): - """Test a half completed tcp frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg1 = b"\x00\x01\x12\x34\x00\x06\xff" - msg2 = b"\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg1, callback, [0, 1]) - assert not result - self._tcp.processIncomingPacket(msg2, callback, [0, 1]) - assert result - assert result.function_code.to_bytes(1,'big') + result.encode() == msg2 - - def test_tcp_framer_transaction_half3(self): - """Test a half completed tcp frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg1 = b"\x00\x01\x12\x34\x00\x06\xff\x02\x01\x02\x00" - msg2 = b"\x08" - self._tcp.processIncomingPacket(msg1, callback, [0, 1]) - assert not result - self._tcp.processIncomingPacket(msg2, callback, [0, 1]) - assert result - assert result.function_code.to_bytes(1,'big') + result.encode() == msg1[7:] + msg2 - - def test_tcp_framer_transaction_short(self): - """Test that we can get back on track after an invalid message.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - # msg1 = b"\x99\x99\x99\x99\x00\x01\x00\x17" - msg1 = b'' - msg2 = b"\x00\x01\x12\x34\x00\x06\xff\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg1, callback, [0, 1]) - assert not result - self._tcp.processIncomingPacket(msg2, callback, [0, 1]) - assert result - assert result.function_code.to_bytes(1,'big') + result.encode() == msg2[7:] - - def test_tcp_framer_populate(self): - """Test a tcp frame packet build.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - expected = ModbusRequest(0, 0, False) - expected.transaction_id = 0x0001 - expected.slave_id = 0xFF - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg, callback, [0, 1]) - # assert self._tcp.checkFrame() - # actual = ModbusRequest(0, 0, 0, False) - # self._tcp.populateResult(actual) - # for name in ("transaction_id", "protocol_id", "slave_id"): - # assert getattr(expected, name) == getattr(actual, name) - - def test_tcp_framer_packet(self): - """Test a tcp frame packet build.""" - old_encode = ModbusRequest.encode - ModbusRequest.encode = lambda self: b"" - message = ModbusRequest(0, 0, False) - message.transaction_id = 0x0001 - message.slave_id = 0xFF - message.function_code = 0x01 - expected = b"\x00\x01\x00\x00\x00\x02\xff\x01" - actual = self._tcp.buildPacket(message) - assert expected == actual - ModbusRequest.encode = old_encode - - # ----------------------------------------------------------------------- # - # TLS tests - # ----------------------------------------------------------------------- # - def test_framer_tls_framer_transaction_ready(self): - """Test a tls frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg[0:4], callback, [0, 1]) - assert not result - self._tcp.processIncomingPacket(msg[4:], callback, [0, 1]) - assert result - - def test_framer_tls_framer_transaction_full(self): - """Test a full tls frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg, callback, [0, 1]) - assert result - - def test_framer_tls_framer_transaction_half(self): - """Test a half completed tls frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg[0:8], callback, [0, 1]) - assert not result - self._tcp.processIncomingPacket(msg[8:], callback, [0, 1]) - assert result - - def test_framer_tls_framer_transaction_short(self): - """Test that we can get back on track after an invalid message.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg[0:2], callback, [0, 1]) - assert not result - self._tcp.processIncomingPacket(msg[2:], callback, [0, 1]) - assert result - - def test_framer_tls_framer_decode(self): - """Testmessage decoding.""" - msg1 = b"" - msg2 = b"\x01\x12\x34\x00\x08" - result = self._tls.decode_data(msg1) - assert not result - result = self._tls.decode_data(msg2) - assert result == {"fcode": 1} - - def test_framer_tls_incoming_packet(self): - """Framer tls incoming packet.""" - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - - slave = 0x01 - msg_result = None - - def mock_callback(result): - """Mock callback.""" - nonlocal msg_result - - msg_result = result.encode() - - self._tls.processIncomingPacket(msg, mock_callback, slave) - # assert msg == msg_result - - # self._tls.isFrameReady = mock.MagicMock(return_value=True) - # x = mock.MagicMock(return_value=False) - # self._tls._validate_slave_id = x - # self._tls.processIncomingPacket(msg, mock_callback, slave) - # assert not self._tls._buffer - # self._tls.advanceFrame() - # x = mock.MagicMock(return_value=True) - # self._tls._validate_slave_id = x - # self._tls.processIncomingPacket(msg, mock_callback, slave) - # assert msg[1:] == msg_result - # self._tls.advanceFrame() - - def test_framer_tls_process(self): - """Framer tls process.""" - # class MockResult: - # """Mock result.""" - - # def __init__(self, code): - # """Init.""" - # self.function_code = code - - # def mock_callback(_arg): - # """Mock callback.""" - - # self._tls.decoder.decode = mock.MagicMock(return_value=None) - # with pytest.raises(ModbusIOException): - # self._tls._process(mock_callback) - - # result = MockResult(0x01) - # self._tls.decoder.decode = mock.MagicMock(return_value=result) - # with pytest.raises(InvalidMessageReceivedException): - # self._tls._process( - # mock_callback, error=True - # ) - # self._tls._process(mock_callback) - # assert not self._tls._buffer - - def test_framer_tls_framer_populate(self): - """Test a tls frame packet build.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg, callback, [0, 1]) - assert result - - def test_framer_tls_framer_packet(self): - """Test a tls frame packet build.""" - old_encode = ModbusRequest.encode - ModbusRequest.encode = lambda self: b"" - message = ModbusRequest(0, 0, False) - message.function_code = 0x01 - expected = b"\x01" - actual = self._tls.buildPacket(message) - assert expected == actual - ModbusRequest.encode = old_encode - - # ----------------------------------------------------------------------- # - # RTU tests - # ----------------------------------------------------------------------- # - def test_rtu_framer_transaction_ready(self): - """Test if the checks for a complete frame work.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg_parts = [b"\x00\x01\x00", b"\x00\x00\x01\xfc\x1b"] - self._rtu.processIncomingPacket(msg_parts[0], callback, [0, 1]) - assert not result - self._rtu.processIncomingPacket(msg_parts[1], callback, [0, 1]) - assert result - - def test_rtu_framer_transaction_full(self): - """Test a full rtu frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" - self._rtu.processIncomingPacket(msg, callback, [0, 1]) - assert result - - def test_rtu_framer_transaction_half(self): - """Test a half completed rtu frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg_parts = [b"\x00\x01\x00", b"\x00\x00\x01\xfc\x1b"] - self._rtu.processIncomingPacket(msg_parts[0], callback, [0, 1]) - assert not result - self._rtu.processIncomingPacket(msg_parts[1], callback, [0, 1]) - assert result - - def test_rtu_framer_populate(self): - """Test a rtu frame packet build.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" - self._rtu.processIncomingPacket(msg, callback, [0, 1]) - assert int(msg[0]) == self._rtu.dev_id - - def test_rtu_framer_packet(self): - """Test a rtu frame packet build.""" - old_encode = ModbusRequest.encode - ModbusRequest.encode = lambda self: b"" - message = ModbusRequest(0, 0, False) - message.slave_id = 0xFF - message.function_code = 0x01 - expected = b"\xff\x01\x81\x80" # only header + CRC - no data - actual = self._rtu.buildPacket(message) - assert expected == actual - ModbusRequest.encode = old_encode - - def test_rtu_decode_exception(self): - """Test that the RTU framer can decode errors.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x90\x02\x9c\x01" - self._rtu.processIncomingPacket(msg, callback, [0, 1]) - assert result - - def test_process(self): - """Test process.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" - self._rtu.processIncomingPacket(msg, callback, [0, 1]) - assert result - - def test_rtu_process_incoming_packets(self): - """Test rtu process incoming packets.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" - slave = 0x00 - - self._rtu.processIncomingPacket(msg, callback, slave) - assert result - - # ----------------------------------------------------------------------- # - # ASCII tests - # ----------------------------------------------------------------------- # - def test_ascii_framer_transaction_ready(self): - """Test a ascii frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b":F7031389000A60\r\n" - self._ascii.processIncomingPacket(msg, callback, [0,1]) - assert result - - def test_ascii_framer_transaction_full(self): - """Test a full ascii frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b"sss:F7031389000A60\r\n" - self._ascii.processIncomingPacket(msg, callback, [0,1]) - assert result - - def test_ascii_framer_transaction_half(self): - """Test a half completed ascii frame transaction.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg_parts = (b"sss:F7031389", b"000A60\r\n") - self._ascii.processIncomingPacket(msg_parts[0], callback, [0,1]) - assert not result - self._ascii.processIncomingPacket(msg_parts[1], callback, [0,1]) - assert result - - def test_ascii_framer_populate(self): - """Test a ascii frame packet build.""" - request = ModbusRequest(0, 0, False) - self._ascii.populateResult(request) - assert not request.slave_id - - def test_ascii_framer_packet(self): - """Test a ascii frame packet build.""" - old_encode = ModbusRequest.encode - ModbusRequest.encode = lambda self: b"" - message = ModbusRequest(0, 0, False) - message.slave_id = 0xFF - message.function_code = 0x01 - expected = b":FF0100\r\n" - actual = self._ascii.buildPacket(message) - assert expected == actual - ModbusRequest.encode = old_encode - - def test_ascii_process_incoming_packets(self): - """Test ascii process incoming packet.""" - count = 0 - result = None - def callback(data): - """Simulate callback.""" - nonlocal count, result - count += 1 - result = data - - msg = b":F7031389000A60\r\n" - self._ascii.processIncomingPacket(msg, callback, [0,1]) - assert result diff --git a/test/framers/test_tls.py b/test/framers/test_tls.py deleted file mode 100644 index 50ccce800..000000000 --- a/test/framers/test_tls.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Test framer.""" - -import pytest - -from pymodbus.framer.tls import FramerTLS - - -class TestMFramerTLS: - """Test module.""" - - @staticmethod - @pytest.fixture(name="frame") - def prepare_frame(): - """Return message object.""" - return FramerTLS() - - - @pytest.mark.parametrize( - ("packet", "used_len"), - [ - (b"\x03\x01\x14\xb5", 4), - (b"\x84\x02", 2), - ], - ) - def test_decode(self, frame, packet, used_len,): - """Test decode.""" - res_len, tid, dev_id, data = frame.decode(packet) - assert res_len == used_len - assert packet == data - assert not tid - assert not dev_id - - - @pytest.mark.parametrize( - ("data"), - [ - (b'\x01\x05\x04\x00\x17'), - (b'\x03\x07\x06\x00\x73'), - (b'\x08\x00\x01'), - (b'\x84\x01'), - ], - ) - def test_roundtrip(self, frame, data): - """Test encode.""" - msg = frame.encode(data, 0, 0) - res_len, res_tid, res_id, res_data = frame.decode(msg) - assert data == res_data - assert not res_id - assert not res_tid diff --git a/test/sub_current/test_transaction.py b/test/sub_current/test_transaction.py index e7cb2b821..54bec29cc 100755 --- a/test/sub_current/test_transaction.py +++ b/test/sub_current/test_transaction.py @@ -102,12 +102,6 @@ def test_execute(self, mock_get_transaction, mock_recv): client.framer.buildPacket.return_value = b"deadbeef" client.framer.sendPacket = mock.MagicMock() client.framer.sendPacket.return_value = len(b"deadbeef") - client.framer.decode_data = mock.MagicMock() - client.framer.decode_data.return_value = { - "slave": 1, - "fcode": 222, - "length": 27, - } request = mock.MagicMock() request.get_response_pdu_size.return_value = 10 request.slave_id = 1 @@ -391,15 +385,6 @@ def callback(data): self._tcp.processIncomingPacket(msg[2:], callback, [0, 1]) assert result - def test_framer_tls_framer_decode(self): - """Testmessage decoding.""" - msg1 = b"" - msg2 = b"\x01\x12\x34\x00\x08" - result = self._tls.decode_data(msg1) - assert not result - result = self._tls.decode_data(msg2) - assert result == {"fcode": 1} - def test_framer_tls_incoming_packet(self): """Framer tls incoming packet.""" msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" From 55f65094b525d8d818b9f5f565e2e8c36ea96db4 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 7 Oct 2024 13:45:58 +0200 Subject: [PATCH 35/41] frameProcessIncomingPacket removed (#2355) --- pymodbus/framer/old_framer_ascii.py | 24 ----------- pymodbus/framer/old_framer_base.py | 61 ++++++++++++++-------------- pymodbus/framer/old_framer_rtu.py | 19 --------- pymodbus/framer/old_framer_socket.py | 38 ----------------- pymodbus/framer/old_framer_tls.py | 21 ---------- pymodbus/server/async_io.py | 3 -- test/sub_current/test_transaction.py | 22 ---------- 7 files changed, 30 insertions(+), 158 deletions(-) diff --git a/pymodbus/framer/old_framer_ascii.py b/pymodbus/framer/old_framer_ascii.py index 753bc4633..61215d18e 100644 --- a/pymodbus/framer/old_framer_ascii.py +++ b/pymodbus/framer/old_framer_ascii.py @@ -1,7 +1,5 @@ """Ascii_framer.""" -from pymodbus.exceptions import ModbusIOException from pymodbus.framer.old_framer_base import BYTE_ORDER, FRAME_HEADER, ModbusFramer -from pymodbus.logging import Log from .ascii import FramerAscii @@ -37,25 +35,3 @@ def __init__(self, decoder, client=None): self._start = b":" self._end = b"\r\n" self.message_handler = FramerAscii(decoder, [0]) - - def frameProcessIncomingPacket(self, single, callback, slave, tid=None): - """Process new packet pattern.""" - while len(self._buffer): - used_len, data = self.message_handler.decode(self._buffer) - if not data: - if not used_len: - return - self._buffer = self._buffer[used_len :] - continue - self.dev_id = self.message_handler.incoming_dev_id - if not self._validate_slave_id(slave, single): - Log.error("Not a valid slave id - {}, ignoring!!", self.message_handler.incoming_dev_id) - self.resetFrame() - return - - if (result := self.decoder.decode(data)) is None: - raise ModbusIOException("Unable to decode response") - self.populateResult(result) - self._buffer = self._buffer[used_len :] - self.dev_id = 0 - callback(result) # defer this diff --git a/pymodbus/framer/old_framer_base.py b/pymodbus/framer/old_framer_base.py index 1b425f6f9..85509a1fd 100644 --- a/pymodbus/framer/old_framer_base.py +++ b/pymodbus/framer/old_framer_base.py @@ -1,10 +1,10 @@ """Framer start.""" -# pylint: disable=missing-type-doc from __future__ import annotations import time from typing import TYPE_CHECKING +from pymodbus.exceptions import ModbusIOException from pymodbus.factory import ClientDecoder, ServerDecoder from pymodbus.framer.base import FramerBase from pymodbus.logging import Log @@ -44,16 +44,14 @@ def __init__( self.tid = 0 self.dev_id = 0 - def _validate_slave_id(self, slaves: list, single: bool) -> bool: + def _validate_slave_id(self, slaves: list) -> bool: """Validate if the received data is valid for the client. :param slaves: list of slave id for which the transaction is valid :param single: Set to true to treat this as a single context :return: """ - if single: - return True - if 0 in slaves or 0xFF in slaves: + if not slaves or 0 in slaves or 0xFF in slaves: # Handle Modbus TCP slave identifier (0x00 0r 0xFF) # in asynchronous requests return True @@ -95,18 +93,7 @@ def resetFrame(self): self.dev_id = 0 self.tid = 0 - def populateResult(self, result): - """Populate the modbus result header. - - The serial packets do not have any header information - that is copied. - - :param result: The response packet - """ - result.slave_id = self.dev_id - result.transaction_id = self.tid - - def processIncomingPacket(self, data: bytes, callback, slave, single=False, tid=None): + def processIncomingPacket(self, data: bytes, callback, slave, tid=None): """Process new packet pattern. This takes in a new request packet, adds it to the current @@ -117,14 +104,6 @@ def processIncomingPacket(self, data: bytes, callback, slave, single=False, tid= The processed and decoded messages are pushed to the callback function to process and send. - - :param data: The new packet data - :param callback: The function to send results to - :param slave: Process if slave id matches, ignore otherwise (could be a - list of slave ids (server) or single slave id(client/server)) - :param single: multiple slave ? - :param tid: transaction id - :raises ModbusIOException: """ Log.debug("Processing: {}", data, ":hex") self._buffer += data @@ -132,12 +111,32 @@ def processIncomingPacket(self, data: bytes, callback, slave, single=False, tid= return if not isinstance(slave, (list, tuple)): slave = [slave] - self.frameProcessIncomingPacket(single, callback, slave, tid=tid) - - def frameProcessIncomingPacket( - self, _single, _callback, _slave, tid=None - ) -> None: - """Process new packet pattern.""" + while True: + if self._buffer == b'': + return + used_len, data = self.message_handler.decode(self._buffer) + self.dev_id = self.message_handler.incoming_dev_id + if used_len: + self._buffer = self._buffer[used_len:] + if not data: + return + self.dev_id = self.message_handler.incoming_dev_id + self.tid = self.message_handler.incoming_tid + if not self._validate_slave_id(slave): + Log.debug("Not a valid slave id - {}, ignoring!!", self.message_handler.incoming_dev_id) + self.resetFrame() + continue + if (result := self.decoder.decode(data)) is None: + self.resetFrame() + raise ModbusIOException("Unable to decode request") + result.slave_id = self.dev_id + result.transaction_id = self.tid + Log.debug("Frame advanced, resetting header!!") + self._buffer = self._buffer[used_len:] + if tid and result.transaction_id and tid != result.transaction_id: + self.resetFrame() + else: + callback(result) # defer or push to a thread? def buildPacket(self, message: ModbusRequest | ModbusResponse) -> bytes: """Create a ready to send modbus packet. diff --git a/pymodbus/framer/old_framer_rtu.py b/pymodbus/framer/old_framer_rtu.py index c7c65a0ea..68012eb7a 100644 --- a/pymodbus/framer/old_framer_rtu.py +++ b/pymodbus/framer/old_framer_rtu.py @@ -1,7 +1,6 @@ """RTU framer.""" import time -from pymodbus.exceptions import ModbusIOException from pymodbus.framer.old_framer_base import BYTE_ORDER, FRAME_HEADER, ModbusFramer from pymodbus.framer.rtu import FramerRTU from pymodbus.logging import Log @@ -55,24 +54,6 @@ def __init__(self, decoder, client=None): super().__init__(decoder, client) self.message_handler: FramerRTU = FramerRTU(self.decoder, [0]) - def frameProcessIncomingPacket(self, _single, callback, _slave, tid=None): - """Process new packet pattern.""" - while True: - if self._buffer == b'': - break - used_len, data = self.message_handler.decode(self._buffer) - self.dev_id = self.message_handler.incoming_dev_id - if used_len: - self._buffer = self._buffer[used_len:] - if not data: - break - if (result := self.decoder.decode(data)) is None: - raise ModbusIOException("Unable to decode request") - result.slave_id = self.dev_id - result.transaction_id = 0 - Log.debug("Frame advanced, resetting header!!") - callback(result) # defer or push to a thread? - def sendPacket(self, message: bytes) -> int: """Send packets on the bus with 3.5char delay between frames. diff --git a/pymodbus/framer/old_framer_socket.py b/pymodbus/framer/old_framer_socket.py index 520be9cdd..8923ccb38 100644 --- a/pymodbus/framer/old_framer_socket.py +++ b/pymodbus/framer/old_framer_socket.py @@ -1,11 +1,7 @@ """Socket framer.""" -from pymodbus.exceptions import ( - ModbusIOException, -) from pymodbus.framer.old_framer_base import ModbusFramer from pymodbus.framer.socket import FramerSocket -from pymodbus.logging import Log # --------------------------------------------------------------------------- # @@ -40,37 +36,3 @@ def __init__(self, decoder, client=None): super().__init__(decoder, client) self._hsize = 0x07 self.message_handler = FramerSocket(decoder, [0]) - - def frameProcessIncomingPacket(self, single, callback, slave, tid=None): - """Process new packet pattern. - - This takes in a new request packet, adds it to the current - packet stream, and performs framing on it. That is, checks - for complete messages, and once found, will process all that - exist. This handles the case when we read N + 1 or 1 // N - messages at a time instead of 1. - - The processed and decoded messages are pushed to the callback - function to process and send. - """ - while True: - if self._buffer == b'': - return - used_len, data = self.message_handler.decode(self._buffer) - if not data: - return - self.dev_id = self.message_handler.incoming_dev_id - self.tid = self.message_handler.incoming_tid - if not self._validate_slave_id(slave, single): - Log.debug("Not a valid slave id - {}, ignoring!!", self.message_handler.incoming_dev_id) - self.resetFrame() - return - if (result := self.decoder.decode(data)) is None: - self.resetFrame() - raise ModbusIOException("Unable to decode request") - self.populateResult(result) - self._buffer: bytes = self._buffer[used_len:] - if tid and tid != result.transaction_id: - self.resetFrame() - else: - callback(result) # defer or push to a thread? diff --git a/pymodbus/framer/old_framer_tls.py b/pymodbus/framer/old_framer_tls.py index 196ca7627..6cb4a3e23 100644 --- a/pymodbus/framer/old_framer_tls.py +++ b/pymodbus/framer/old_framer_tls.py @@ -1,8 +1,5 @@ """TLS framer.""" -from pymodbus.exceptions import ( - ModbusIOException, -) from pymodbus.framer.old_framer_base import ModbusFramer from pymodbus.framer.tls import FramerTLS @@ -31,21 +28,3 @@ def __init__(self, decoder, client=None): super().__init__(decoder, client) self._hsize = 0x0 self.message_handler = FramerTLS(decoder, [0]) - - def frameProcessIncomingPacket(self, _single, callback, _slave, tid=None): - """Process new packet pattern.""" - # no slave id for Modbus Security Application Protocol - - while True: - used_len, data = self.message_handler.decode(self._buffer) - if not data: - return - self.dev_id = self.message_handler.incoming_dev_id - self.tid = self.message_handler.incoming_tid - - if (result := self.decoder.decode(data)) is None: - self.resetFrame() - raise ModbusIOException("Unable to decode request") - self.populateResult(result) - self._buffer: bytes = self._buffer[used_len:] - callback(result) # defer or push to a thread? diff --git a/pymodbus/server/async_io.py b/pymodbus/server/async_io.py index ad1dc7758..40aaca12d 100644 --- a/pymodbus/server/async_io.py +++ b/pymodbus/server/async_io.py @@ -122,13 +122,10 @@ async def inner_handle(self): slaves.append(0) Log.debug("Handling data: {}", data, ":hex") - - single = self.server.context.single self.framer.processIncomingPacket( data=data, callback=lambda x: self.execute(x, *addr), slave=slaves, - single=single, ) async def handle(self) -> None: diff --git a/test/sub_current/test_transaction.py b/test/sub_current/test_transaction.py index 54bec29cc..0a647ff01 100755 --- a/test/sub_current/test_transaction.py +++ b/test/sub_current/test_transaction.py @@ -302,11 +302,6 @@ def callback(data): expected.slave_id = 0xFF msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" self._tcp.processIncomingPacket(msg, callback, [0, 1]) - # assert self._tcp.checkFrame() - # actual = ModbusRequest() - # self._tcp.populateResult(actual) - # for name in ("transaction_id", "protocol_id", "slave_id"): - # assert getattr(expected, name) == getattr(actual, name) @mock.patch.object(ModbusRequest, "encode") def test_tcp_framer_packet(self, mock_encode): @@ -627,23 +622,6 @@ def callback(data): self._ascii.processIncomingPacket(msg_parts[1], callback, [0,1]) assert result - def test_ascii_framer_populate(self): - """Test a ascii frame packet build.""" - request = ModbusRequest(0, 0, False) - self._ascii.populateResult(request) - assert not request.slave_id - - @mock.patch.object(ModbusRequest, "encode") - def test_ascii_framer_packet(self, mock_encode): - """Test a ascii frame packet build.""" - message = ModbusRequest(0, 0, False) - message.slave_id = 0xFF - message.function_code = 0x01 - expected = b":FF0100\r\n" - mock_encode.return_value = b"" - actual = self._ascii.buildPacket(message) - assert expected == actual - def test_ascii_process_incoming_packets(self): """Test ascii process incoming packet.""" count = 0 From 11d00df6e0ea17423ec66938cb9ff683025531d7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 7 Oct 2024 18:47:56 +0200 Subject: [PATCH 36/41] Reduce old_framers (#2356) --- examples/message_parser.py | 2 +- pymodbus/client/base.py | 5 +- pymodbus/client/modbusclientprotocol.py | 1 - pymodbus/client/serial.py | 48 ++++++++- pymodbus/client/tcp.py | 2 +- pymodbus/client/udp.py | 5 +- pymodbus/framer/base.py | 54 +++++++++++ pymodbus/framer/old_framer_ascii.py | 6 +- pymodbus/framer/old_framer_base.py | 124 +++--------------------- pymodbus/framer/old_framer_rtu.py | 56 +---------- pymodbus/framer/old_framer_socket.py | 4 +- pymodbus/framer/old_framer_tls.py | 4 +- pymodbus/transaction.py | 18 ++-- test/sub_client/test_client_sync.py | 4 +- test/sub_current/test_transaction.py | 6 +- 15 files changed, 141 insertions(+), 198 deletions(-) diff --git a/examples/message_parser.py b/examples/message_parser.py index 10c807f81..3b2dfde3e 100755 --- a/examples/message_parser.py +++ b/examples/message_parser.py @@ -77,7 +77,7 @@ def decode(self, message): self.framer(ClientDecoder(), client=None), ] for decoder in decoders: - print(f"{decoder.decoder.__class__.__name__}") + print(f"{decoder.message_handler.decoder.__class__.__name__}") print("-" * 80) try: decoder.processIncomingPacket(message, self.report, 0) diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index cf541724c..1faf709b8 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -77,7 +77,7 @@ def register(self, custom_response_class: ModbusResponse) -> None: Use register() to add non-standard responses (like e.g. a login prompt) and have them interpreted automatically. """ - self.ctx.framer.decoder.register(custom_response_class) + self.ctx.framer.message_handler.decoder.register(custom_response_class) def close(self) -> None: """Close connection.""" @@ -108,7 +108,6 @@ async def async_execute(self, request) -> ModbusResponse: while count <= self.retries: async with self._lock: req = self.build_response(request) - self.ctx.framer.resetFrame() self.ctx.send(packet) if not request.slave_id: resp = None @@ -214,7 +213,7 @@ def register(self, custom_response_class: ModbusResponse) -> None: Use register() to add non-standard responses (like e.g. a login prompt) and have them interpreted automatically. """ - self.framer.decoder.register(custom_response_class) + self.framer.message_handler.decoder.register(custom_response_class) def idle_time(self) -> float: """Time before initiating next transaction (call **sync**). diff --git a/pymodbus/client/modbusclientprotocol.py b/pymodbus/client/modbusclientprotocol.py index 22de94fa1..0c64bf1c9 100644 --- a/pymodbus/client/modbusclientprotocol.py +++ b/pymodbus/client/modbusclientprotocol.py @@ -55,7 +55,6 @@ def callback_connected(self) -> None: """Call when connection is succcesfull.""" if self.on_connect_callback: self.loop.call_soon(self.on_connect_callback, True) - self.framer.resetFrame() def callback_disconnected(self, exc: Exception | None) -> None: """Call when connection is lost.""" diff --git a/pymodbus/client/serial.py b/pymodbus/client/serial.py index 9e3814b84..fe74fb99f 100644 --- a/pymodbus/client/serial.py +++ b/pymodbus/client/serial.py @@ -239,7 +239,7 @@ def _in_waiting(self): """Return waiting bytes.""" return getattr(self.socket, "in_waiting") if hasattr(self.socket, "in_waiting") else getattr(self.socket, "inWaiting")() - def send(self, request: bytes) -> int: + def _send(self, request: bytes) -> int: """Send data on the underlying socket. If receive buffer still holds some data then flush it. @@ -258,6 +258,51 @@ def send(self, request: bytes) -> int: return size return 0 + def send(self, request: bytes) -> int: + """Send data on the underlying socket.""" + start = time.time() + if hasattr(self,"ctx"): + timeout = start + self.ctx.comm_params.timeout_connect + else: + timeout = start + self.comm_params.timeout_connect + while self.state != ModbusTransactionState.IDLE: + if self.state == ModbusTransactionState.TRANSACTION_COMPLETE: + timestamp = round(time.time(), 6) + Log.debug( + "Changing state to IDLE - Last Frame End - {} Current Time stamp - {}", + self.last_frame_end, + timestamp, + ) + if self.last_frame_end: + idle_time = self.idle_time() + if round(timestamp - idle_time, 6) <= self.silent_interval: + Log.debug( + "Waiting for 3.5 char before next send - {} ms", + self.silent_interval * 1000, + ) + time.sleep(self.silent_interval) + else: + # Recovering from last error ?? + time.sleep(self.silent_interval) + self.state = ModbusTransactionState.IDLE + elif self.state == ModbusTransactionState.RETRYING: + # Simple lets settle down!!! + # To check for higher baudrates + time.sleep(self.comm_params.timeout_connect) + break + elif time.time() > timeout: + Log.debug( + "Spent more time than the read time out, " + "resetting the transaction to IDLE" + ) + self.state = ModbusTransactionState.IDLE + else: + Log.debug("Sleeping") + time.sleep(self.silent_interval) + size = self._send(request) + self.last_frame_end = round(time.time(), 6) + return size + def _wait_for_data(self) -> int: """Wait for data.""" size = 0 @@ -286,6 +331,7 @@ def recv(self, size: int | None) -> bytes: if size > self._in_waiting(): self._wait_for_data() result = self.socket.read(size) + self.last_frame_end = round(time.time(), 6) return result def is_socket_open(self) -> bool: diff --git a/pymodbus/client/tcp.py b/pymodbus/client/tcp.py index 0f6f0de44..360a80e0f 100644 --- a/pymodbus/client/tcp.py +++ b/pymodbus/client/tcp.py @@ -245,7 +245,7 @@ def recv(self, size: int | None) -> bytes: # size and the slave sends noisy data continuously. if time_ > end: break - + self.last_frame_end = round(time.time(), 6) return b"".join(data) def _handle_abrupt_socket_close(self, size: int | None, data: list[bytes], duration: float) -> bytes: diff --git a/pymodbus/client/udp.py b/pymodbus/client/udp.py index 296d1ae46..d3c68612c 100644 --- a/pymodbus/client/udp.py +++ b/pymodbus/client/udp.py @@ -2,6 +2,7 @@ from __future__ import annotations import socket +import time from collections.abc import Callable from pymodbus.client.base import ModbusBaseClient, ModbusBaseSyncClient @@ -202,7 +203,9 @@ def recv(self, size: int | None) -> bytes: raise ConnectionException(str(self)) if size is None: size = 0 - return self.socket.recvfrom(size)[0] + data = self.socket.recvfrom(size)[0] + self.last_frame_end = round(time.time(), 6) + return data def is_socket_open(self): """Check if socket is open. diff --git a/pymodbus/framer/base.py b/pymodbus/framer/base.py index 326f66978..889176c17 100644 --- a/pymodbus/framer/base.py +++ b/pymodbus/framer/base.py @@ -8,8 +8,10 @@ from abc import abstractmethod +from pymodbus.exceptions import ModbusIOException from pymodbus.factory import ClientDecoder, ServerDecoder from pymodbus.logging import Log +from pymodbus.pdu import ModbusRequest, ModbusResponse class FramerBase: @@ -28,6 +30,7 @@ def __init__( self.dev_ids = dev_ids self.incoming_dev_id = 0 self.incoming_tid = 0 + self.databuffer = b"" def decode(self, data: bytes) -> tuple[int, bytes]: """Decode ADU. @@ -62,3 +65,54 @@ def encode(self, pdu: bytes, dev_id: int, tid: int) -> bytes: returns: modbus ADU (bytes) """ + + def buildPacket(self, message: ModbusRequest | ModbusResponse) -> bytes: + """Create a ready to send modbus packet. + + :param message: The populated request/response to send + """ + data = message.function_code.to_bytes(1,'big') + message.encode() + packet = self.encode(data, message.slave_id, message.transaction_id) + return packet + + def processIncomingPacket(self, data: bytes, callback, slave, tid=None): + """Process new packet pattern. + + This takes in a new request packet, adds it to the current + packet stream, and performs framing on it. That is, checks + for complete messages, and once found, will process all that + exist. This handles the case when we read N + 1 or 1 // N + messages at a time instead of 1. + + The processed and decoded messages are pushed to the callback + function to process and send. + """ + Log.debug("Processing: {}", data, ":hex") + self.databuffer += data + if self.databuffer == b'': + return + if not isinstance(slave, (list, tuple)): + slave = [slave] + while True: + if self.databuffer == b'': + return + used_len, data = self.decode(self.databuffer) + if used_len: + self.databuffer = self.databuffer[used_len:] + if not data: + return + if slave and 0 not in slave and self.incoming_dev_id not in slave: + Log.debug("Not a valid slave id - {}, ignoring!!", self.incoming_dev_id) + self.databuffer = b'' + continue + if (result := self.decoder.decode(data)) is None: + self.databuffer = b'' + raise ModbusIOException("Unable to decode request") + result.slave_id = self.incoming_dev_id + result.transaction_id = self.incoming_tid + Log.debug("Frame advanced, resetting header!!") + self.databuffer = self.databuffer[used_len:] + if tid and result.transaction_id and tid != result.transaction_id: + self.databuffer = b'' + else: + callback(result) # defer or push to a thread? diff --git a/pymodbus/framer/old_framer_ascii.py b/pymodbus/framer/old_framer_ascii.py index 61215d18e..0ca277e0c 100644 --- a/pymodbus/framer/old_framer_ascii.py +++ b/pymodbus/framer/old_framer_ascii.py @@ -30,8 +30,4 @@ def __init__(self, decoder, client=None): :param decoder: The decoder implementation to use """ - super().__init__(decoder, client) - self._hsize = 0x02 - self._start = b":" - self._end = b"\r\n" - self.message_handler = FramerAscii(decoder, [0]) + super().__init__(decoder, client, FramerAscii) diff --git a/pymodbus/framer/old_framer_base.py b/pymodbus/framer/old_framer_base.py index 85509a1fd..c1cc32d0e 100644 --- a/pymodbus/framer/old_framer_base.py +++ b/pymodbus/framer/old_framer_base.py @@ -1,18 +1,15 @@ """Framer start.""" from __future__ import annotations -import time from typing import TYPE_CHECKING -from pymodbus.exceptions import ModbusIOException from pymodbus.factory import ClientDecoder, ServerDecoder from pymodbus.framer.base import FramerBase -from pymodbus.logging import Log from pymodbus.pdu import ModbusRequest, ModbusResponse if TYPE_CHECKING: - from pymodbus.client.base import ModbusBaseSyncClient + pass # Unit ID, Function Code BYTE_ORDER = ">" @@ -31,118 +28,29 @@ class ModbusFramer: def __init__( self, decoder: ClientDecoder | ServerDecoder, - client: ModbusBaseSyncClient, + _client, + new_framer, ) -> None: """Initialize a new instance of the framer. :param decoder: The decoder implementation to use """ - self.decoder = decoder - self.client = client - self._buffer = b"" - self.message_handler: FramerBase - self.tid = 0 - self.dev_id = 0 + self.message_handler: FramerBase = new_framer(decoder, [0]) - def _validate_slave_id(self, slaves: list) -> bool: - """Validate if the received data is valid for the client. + @property + def incoming_dev_id(self) -> int: + """Return dev id.""" + return self.message_handler.incoming_dev_id - :param slaves: list of slave id for which the transaction is valid - :param single: Set to true to treat this as a single context - :return: - """ - if not slaves or 0 in slaves or 0xFF in slaves: - # Handle Modbus TCP slave identifier (0x00 0r 0xFF) - # in asynchronous requests - return True - return self.dev_id in slaves - - def sendPacket(self, message: bytes): - """Send packets on the bus. - - With 3.5char delay between frames - :param message: Message to be sent over the bus - :return: - """ - return self.client.send(message) - - def recvPacket(self, size: int) -> bytes: - """Receive packet from the bus. - - With specified len - :param size: Number of bytes to read - :return: - """ - packet = self.client.recv(size) - self.client.last_frame_end = round(time.time(), 6) - return packet - - def resetFrame(self): - """Reset the entire message frame. - - This allows us to skip ovver errors that may be in the stream. - It is hard to know if we are simply out of sync or if there is - an error in the stream as we have no way to check the start or - end of the message (python just doesn't have the resolution to - check for millisecond delays). - """ - Log.debug( - "Resetting frame - Current Frame in buffer - {}", self._buffer, ":hex" - ) - self._buffer = b"" - self.dev_id = 0 - self.tid = 0 + @property + def incoming_tid(self) -> int: + """Return tid.""" + return self.message_handler.incoming_tid def processIncomingPacket(self, data: bytes, callback, slave, tid=None): - """Process new packet pattern. - - This takes in a new request packet, adds it to the current - packet stream, and performs framing on it. That is, checks - for complete messages, and once found, will process all that - exist. This handles the case when we read N + 1 or 1 // N - messages at a time instead of 1. - - The processed and decoded messages are pushed to the callback - function to process and send. - """ - Log.debug("Processing: {}", data, ":hex") - self._buffer += data - if self._buffer == b'': - return - if not isinstance(slave, (list, tuple)): - slave = [slave] - while True: - if self._buffer == b'': - return - used_len, data = self.message_handler.decode(self._buffer) - self.dev_id = self.message_handler.incoming_dev_id - if used_len: - self._buffer = self._buffer[used_len:] - if not data: - return - self.dev_id = self.message_handler.incoming_dev_id - self.tid = self.message_handler.incoming_tid - if not self._validate_slave_id(slave): - Log.debug("Not a valid slave id - {}, ignoring!!", self.message_handler.incoming_dev_id) - self.resetFrame() - continue - if (result := self.decoder.decode(data)) is None: - self.resetFrame() - raise ModbusIOException("Unable to decode request") - result.slave_id = self.dev_id - result.transaction_id = self.tid - Log.debug("Frame advanced, resetting header!!") - self._buffer = self._buffer[used_len:] - if tid and result.transaction_id and tid != result.transaction_id: - self.resetFrame() - else: - callback(result) # defer or push to a thread? + """Process new packet pattern.""" + self.message_handler.processIncomingPacket(data, callback, slave, tid=tid) def buildPacket(self, message: ModbusRequest | ModbusResponse) -> bytes: - """Create a ready to send modbus packet. - - :param message: The populated request/response to send - """ - data = message.function_code.to_bytes(1,'big') + message.encode() - packet = self.message_handler.encode(data, message.slave_id, message.transaction_id) - return packet + """Create a ready to send modbus packet.""" + return self.message_handler.buildPacket(message) diff --git a/pymodbus/framer/old_framer_rtu.py b/pymodbus/framer/old_framer_rtu.py index 68012eb7a..5a5efc430 100644 --- a/pymodbus/framer/old_framer_rtu.py +++ b/pymodbus/framer/old_framer_rtu.py @@ -1,10 +1,7 @@ """RTU framer.""" -import time from pymodbus.framer.old_framer_base import BYTE_ORDER, FRAME_HEADER, ModbusFramer from pymodbus.framer.rtu import FramerRTU -from pymodbus.logging import Log -from pymodbus.utilities import ModbusTransactionState RTU_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER @@ -51,55 +48,4 @@ def __init__(self, decoder, client=None): :param decoder: The decoder factory implementation to use """ - super().__init__(decoder, client) - self.message_handler: FramerRTU = FramerRTU(self.decoder, [0]) - - def sendPacket(self, message: bytes) -> int: - """Send packets on the bus with 3.5char delay between frames. - - :param message: Message to be sent over the bus - :return: - """ - super().resetFrame() - start = time.time() - if hasattr(self.client,"ctx"): - timeout = start + self.client.ctx.comm_params.timeout_connect - else: - timeout = start + self.client.comm_params.timeout_connect - while self.client.state != ModbusTransactionState.IDLE: - if self.client.state == ModbusTransactionState.TRANSACTION_COMPLETE: - timestamp = round(time.time(), 6) - Log.debug( - "Changing state to IDLE - Last Frame End - {} Current Time stamp - {}", - self.client.last_frame_end, - timestamp, - ) - if self.client.last_frame_end: - idle_time = self.client.idle_time() - if round(timestamp - idle_time, 6) <= self.client.silent_interval: - Log.debug( - "Waiting for 3.5 char before next send - {} ms", - self.client.silent_interval * 1000, - ) - time.sleep(self.client.silent_interval) - else: - # Recovering from last error ?? - time.sleep(self.client.silent_interval) - self.client.state = ModbusTransactionState.IDLE - elif self.client.state == ModbusTransactionState.RETRYING: - # Simple lets settle down!!! - # To check for higher baudrates - time.sleep(self.client.comm_params.timeout_connect) - break - elif time.time() > timeout: - Log.debug( - "Spent more time than the read time out, " - "resetting the transaction to IDLE" - ) - self.client.state = ModbusTransactionState.IDLE - else: - Log.debug("Sleeping") - time.sleep(self.client.silent_interval) - size = self.client.send(message) - self.client.last_frame_end = round(time.time(), 6) - return size + super().__init__(decoder, client, FramerRTU) diff --git a/pymodbus/framer/old_framer_socket.py b/pymodbus/framer/old_framer_socket.py index 8923ccb38..75a394e7e 100644 --- a/pymodbus/framer/old_framer_socket.py +++ b/pymodbus/framer/old_framer_socket.py @@ -33,6 +33,4 @@ def __init__(self, decoder, client=None): :param decoder: The decoder factory implementation to use """ - super().__init__(decoder, client) - self._hsize = 0x07 - self.message_handler = FramerSocket(decoder, [0]) + super().__init__(decoder, client, FramerSocket) diff --git a/pymodbus/framer/old_framer_tls.py b/pymodbus/framer/old_framer_tls.py index 6cb4a3e23..ff13a6c1c 100644 --- a/pymodbus/framer/old_framer_tls.py +++ b/pymodbus/framer/old_framer_tls.py @@ -25,6 +25,4 @@ def __init__(self, decoder, client=None): :param decoder: The decoder factory implementation to use """ - super().__init__(decoder, client) - self._hsize = 0x0 - self.message_handler = FramerTLS(decoder, [0]) + super().__init__(decoder, client, FramerTLS) diff --git a/pymodbus/transaction.py b/pymodbus/transaction.py index c6f9a6dd8..004f42ae6 100644 --- a/pymodbus/transaction.py +++ b/pymodbus/transaction.py @@ -191,10 +191,10 @@ def execute(self, request: ModbusRequest): # noqa: C901 request.transaction_id = self.getNextTID() Log.debug("Running transaction {}", request.transaction_id) if _buffer := hexlify_packets( - self.client.framer._buffer # pylint: disable=protected-access + self.client.framer.message_handler.databuffer ): Log.debug("Clearing current Frame: - {}", _buffer) - self.client.framer.resetFrame() + self.client.framer.message_handler.databuffer = b'' broadcast = not request.slave_id expected_response_length = None if not isinstance(self.client.framer, ModbusSocketFramer): @@ -339,7 +339,7 @@ def _transact(self, request: ModbusRequest, response_length, full=False, broadca def _send(self, packet: bytes, _retrying=False): """Send.""" - return self.client.framer.sendPacket(packet) + return self.client.send(packet) def _recv(self, expected_response_length, full) -> bytes: # noqa: C901 """Receive.""" @@ -355,7 +355,7 @@ def _recv(self, expected_response_length, full) -> bytes: # noqa: C901 else: min_size = expected_response_length - read_min = self.client.framer.recvPacket(min_size) + read_min = self.client.recv(min_size) if min_size and len(read_min) != min_size: msg_start = "Incomplete message" if read_min else "No response" raise InvalidMessageReceivedException( @@ -374,12 +374,8 @@ def _recv(self, expected_response_length, full) -> bytes: # noqa: C901 if func_code < 0x80: # Not an error if isinstance(self.client.framer, ModbusSocketFramer): - # Omit UID, which is included in header size - h_size = ( - self.client.framer._hsize # pylint: disable=protected-access - ) length = struct.unpack(">H", read_min[4:6])[0] - 1 - expected_response_length = h_size + length + expected_response_length = 7 + length elif expected_response_length is None and isinstance( self.client.framer, ModbusRtuFramer ): @@ -402,7 +398,7 @@ def _recv(self, expected_response_length, full) -> bytes: # noqa: C901 else: read_min = b"" total = expected_response_length - result = self.client.framer.recvPacket(expected_response_length) + result = self.client.recv(expected_response_length) result = read_min + result actual = len(result) if total is not None and actual != total: @@ -433,5 +429,5 @@ def _get_expected_response_length(self, data) -> int: :return: Total frame size """ func_code = int(data[1]) - pdu_class = self.client.framer.decoder.lookupPduClass(func_code) + pdu_class = self.client.framer.message_handler.decoder.lookupPduClass(func_code) return pdu_class.calculateRtuFrameSize(data) diff --git a/test/sub_client/test_client_sync.py b/test/sub_client/test_client_sync.py index 90f5ee862..33dd1d5b0 100755 --- a/test/sub_client/test_client_sync.py +++ b/test/sub_client/test_client_sync.py @@ -206,7 +206,7 @@ class CustomRequest: # pylint: disable=too-few-public-methods client = ModbusTcpClient("127.0.0.1") client.framer = mock.Mock() client.register(CustomRequest) - client.framer.decoder.register.assert_called_once_with(CustomRequest) + client.framer.message_handler.decoder.register.assert_called_once_with(CustomRequest) # -----------------------------------------------------------------------# # Test TLS Client @@ -296,7 +296,7 @@ class CustomRequest: # pylint: disable=too-few-public-methods client = ModbusTlsClient("127.0.0.1") client.framer = mock.Mock() client.register(CustomRequest) - client.framer.decoder.register.assert_called_once_with(CustomRequest) + client.framer.message_handler.decoder.register.assert_called_once_with(CustomRequest) # -----------------------------------------------------------------------# # Test Serial Client diff --git a/test/sub_current/test_transaction.py b/test/sub_current/test_transaction.py index 0a647ff01..152071d9e 100755 --- a/test/sub_current/test_transaction.py +++ b/test/sub_current/test_transaction.py @@ -100,8 +100,8 @@ def test_execute(self, mock_get_transaction, mock_recv): client.framer.processIncomingPacket.return_value = None client.framer.buildPacket = mock.MagicMock() client.framer.buildPacket.return_value = b"deadbeef" - client.framer.sendPacket = mock.MagicMock() - client.framer.sendPacket.return_value = len(b"deadbeef") + client.send = mock.MagicMock() + client.send.return_value = len(b"deadbeef") request = mock.MagicMock() request.get_response_pdu_size.return_value = 10 request.slave_id = 1 @@ -518,7 +518,7 @@ def callback(data): msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" self._rtu.processIncomingPacket(msg, callback, [0, 1]) - assert int(msg[0]) == self._rtu.dev_id + assert int(msg[0]) == self._rtu.incoming_dev_id @mock.patch.object(ModbusRequest, "encode") def test_rtu_framer_packet(self, mock_encode): From 541bae45d381104ed2782c5f48f0eee36e0dab57 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 8 Oct 2024 13:10:35 +0200 Subject: [PATCH 37/41] Remove old framers (#2358) --- doc/source/library/framer.rst | 16 +- doc/source/library/simulator/config.rst | 8 +- examples/client_async.py | 2 +- examples/client_sync.py | 2 +- examples/message_parser.py | 18 +- pymodbus/client/base.py | 9 +- pymodbus/client/modbusclientprotocol.py | 11 +- pymodbus/framer/__init__.py | 21 +- pymodbus/framer/base.py | 19 +- pymodbus/framer/framer.py | 106 ----- pymodbus/framer/old_framer_ascii.py | 33 -- pymodbus/framer/old_framer_base.py | 56 --- pymodbus/framer/old_framer_rtu.py | 51 --- pymodbus/framer/old_framer_socket.py | 36 -- pymodbus/framer/old_framer_tls.py | 28 -- pymodbus/server/async_io.py | 10 +- pymodbus/transaction.py | 46 +-- test/framers/conftest.py | 24 +- test/framers/generator.py | 14 +- test/framers/test_asyncframer.py | 391 ------------------ test/framers/test_multidrop.py | 4 +- .../sub_client/test_client_faulty_response.py | 6 +- test/sub_client/test_client_sync.py | 18 +- test/sub_current/test_transaction.py | 18 +- test/sub_server/test_server_asyncio.py | 6 +- 25 files changed, 110 insertions(+), 843 deletions(-) delete mode 100644 pymodbus/framer/framer.py delete mode 100644 pymodbus/framer/old_framer_ascii.py delete mode 100644 pymodbus/framer/old_framer_base.py delete mode 100644 pymodbus/framer/old_framer_rtu.py delete mode 100644 pymodbus/framer/old_framer_socket.py delete mode 100644 pymodbus/framer/old_framer_tls.py delete mode 100644 test/framers/test_asyncframer.py diff --git a/doc/source/library/framer.rst b/doc/source/library/framer.rst index 32204cfaf..56dcdb652 100644 --- a/doc/source/library/framer.rst +++ b/doc/source/library/framer.rst @@ -1,34 +1,34 @@ Framer ====== -pymodbus\.framer\.ModbusAsciiFramer module +pymodbus\.framer\.FramerAscii module ------------------------------------------ -.. automodule:: pymodbus.framer.ModbusAsciiFramer +.. automodule:: pymodbus.framer.FramerAscii :members: :undoc-members: :show-inheritance: -pymodbus\.framer\.ModbusRtuFramer module +pymodbus\.framer\.FramerRTU module ---------------------------------------- -.. automodule:: pymodbus.framer.ModbusRtuFramer +.. automodule:: pymodbus.framer.FramerRTU :members: :undoc-members: :show-inheritance: -pymodbus\.framer\.ModbusSocketFramer module +pymodbus\.framer\.FramerSocket module ------------------------------------------- -.. automodule:: pymodbus.framer.ModbusSocketFramer +.. automodule:: pymodbus.framer.FramerSocket :members: :undoc-members: :show-inheritance: -pymodbus\.framer\.ModbusTlsFramer module +pymodbus\.framer\.FramerTLS module ---------------------------------------- -.. automodule:: pymodbus.framer.ModbusTlsFramer +.. automodule:: pymodbus.framer.FramerTLS :members: :undoc-members: :show-inheritance: diff --git a/doc/source/library/simulator/config.rst b/doc/source/library/simulator/config.rst index 9349a313c..ea89c262f 100644 --- a/doc/source/library/simulator/config.rst +++ b/doc/source/library/simulator/config.rst @@ -63,10 +63,10 @@ The entry “comm” allows the following values: The entry “framer” allows the following values: -- “ascii” to use :class:`pymodbus.framer.ModbusAsciiFramer`, -- “rtu” to use :class:`pymodbus.framer.ModbusRtuFramer`, -- “tls” to use :class:`pymodbus.framer.ModbusTlsFramer`, -- “socket” to use :class:`pymodbus.framer.ModbusSocketFramer`. +- “ascii” to use :class:`pymodbus.framer.FramerAscii`, +- “rtu” to use :class:`pymodbus.framer.FramerRTU`, +- “socket” to use :class:`pymodbus.framer.FramerSocket`. +- “tls” to use :class:`pymodbus.framer.FramerTLS`, Optional entry "device_id" will limit server to only accept a single id. If not set, the server will accept all device id. diff --git a/examples/client_async.py b/examples/client_async.py index 281b7792d..c14952dbf 100755 --- a/examples/client_async.py +++ b/examples/client_async.py @@ -81,7 +81,7 @@ def setup_async_client(description=None, cmdline=None): client = modbusClient.AsyncModbusSerialClient( args.port, # Common optional parameters: - # framer=ModbusRtuFramer, + # framer=FramerType.RTU, timeout=args.timeout, # retries=3, # Serial setup parameters diff --git a/examples/client_sync.py b/examples/client_sync.py index 6367306d0..0c3a82627 100755 --- a/examples/client_sync.py +++ b/examples/client_sync.py @@ -85,7 +85,7 @@ def setup_sync_client(description=None, cmdline=None): client = modbusClient.ModbusSerialClient( port=args.port, # serial port # Common optional parameters: - # framer=ModbusRtuFramer, + # framer=FramerType.RTU, timeout=args.timeout, # retries=3, # Serial setup parameters diff --git a/examples/message_parser.py b/examples/message_parser.py index 3b2dfde3e..3074a15d1 100755 --- a/examples/message_parser.py +++ b/examples/message_parser.py @@ -13,10 +13,10 @@ from pymodbus import pymodbus_apply_logging_config from pymodbus.factory import ClientDecoder, ServerDecoder -from pymodbus.transaction import ( - ModbusAsciiFramer, - ModbusRtuFramer, - ModbusSocketFramer, +from pymodbus.framer import ( + FramerAscii, + FramerRTU, + FramerSocket, ) @@ -73,8 +73,8 @@ def decode(self, message): print(f"Decoding Message {value}") print("=" * 80) decoders = [ - self.framer(ServerDecoder(), client=None), - self.framer(ClientDecoder(), client=None), + self.framer(ServerDecoder(), [0]), + self.framer(ClientDecoder(), [0]), ] for decoder in decoders: print(f"{decoder.message_handler.decoder.__class__.__name__}") @@ -143,9 +143,9 @@ def parse_messages(cmdline=None): return framer = { - "ascii": ModbusAsciiFramer, - "rtu": ModbusRtuFramer, - "socket": ModbusSocketFramer, + "ascii": FramerAscii, + "rtu": FramerRTU, + "socket": FramerSocket, }[args.framer] decoder = Decoder(framer) diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 1faf709b8..83be824d6 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -5,13 +5,12 @@ import socket from abc import abstractmethod from collections.abc import Awaitable, Callable -from typing import cast from pymodbus.client.mixin import ModbusClientMixin from pymodbus.client.modbusclientprotocol import ModbusClientProtocol from pymodbus.exceptions import ConnectionException, ModbusIOException from pymodbus.factory import ClientDecoder -from pymodbus.framer import FRAMER_NAME_TO_OLD_CLASS, FramerType, ModbusFramer +from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerBase, FramerType from pymodbus.logging import Log from pymodbus.pdu import ModbusRequest, ModbusResponse from pymodbus.transaction import SyncModbusTransactionManager @@ -77,7 +76,7 @@ def register(self, custom_response_class: ModbusResponse) -> None: Use register() to add non-standard responses (like e.g. a login prompt) and have them interpreted automatically. """ - self.ctx.framer.message_handler.decoder.register(custom_response_class) + self.ctx.framer.decoder.register(custom_response_class) def close(self) -> None: """Close connection.""" @@ -187,9 +186,7 @@ def __init__( self.slaves: list[int] = [] # Common variables. - self.framer: ModbusFramer = FRAMER_NAME_TO_OLD_CLASS.get( - framer, cast(type[ModbusFramer], framer) - )(ClientDecoder(), self) + self.framer: FramerBase = (FRAMER_NAME_TO_CLASS[framer])(ClientDecoder(), [0]) self.transaction = SyncModbusTransactionManager( self, self.retries, diff --git a/pymodbus/client/modbusclientprotocol.py b/pymodbus/client/modbusclientprotocol.py index 0c64bf1c9..12a9c2535 100644 --- a/pymodbus/client/modbusclientprotocol.py +++ b/pymodbus/client/modbusclientprotocol.py @@ -2,10 +2,13 @@ from __future__ import annotations from collections.abc import Callable -from typing import cast from pymodbus.factory import ClientDecoder -from pymodbus.framer import FRAMER_NAME_TO_OLD_CLASS, FramerType, ModbusFramer +from pymodbus.framer import ( + FRAMER_NAME_TO_CLASS, + FramerBase, + FramerType, +) from pymodbus.logging import Log from pymodbus.transaction import ModbusTransactionManager from pymodbus.transport import CommParams, ModbusProtocol @@ -32,9 +35,7 @@ def __init__( self.on_connect_callback = on_connect_callback # Common variables. - self.framer = FRAMER_NAME_TO_OLD_CLASS.get( - framer, cast(type[ModbusFramer], framer) - )(ClientDecoder(), self) + self.framer: FramerBase = (FRAMER_NAME_TO_CLASS[framer])(ClientDecoder(), [0]) self.transaction = ModbusTransactionManager() def _handle_response(self, reply): diff --git a/pymodbus/framer/__init__.py b/pymodbus/framer/__init__.py index 9d17d2842..aa40ccc92 100644 --- a/pymodbus/framer/__init__.py +++ b/pymodbus/framer/__init__.py @@ -1,12 +1,6 @@ """Framer.""" __all__ = [ - "FRAMER_NAME_TO_OLD_CLASS", - "ModbusFramer", - "ModbusAsciiFramer", - "ModbusRtuFramer", - "ModbusSocketFramer", - "ModbusTlsFramer", - "AsyncFramer", + "FramerBase", "FramerType", "FramerAscii", "FramerRTU", @@ -15,23 +9,12 @@ ] from pymodbus.framer.ascii import FramerAscii -from pymodbus.framer.framer import AsyncFramer, FramerType -from pymodbus.framer.old_framer_ascii import ModbusAsciiFramer -from pymodbus.framer.old_framer_base import ModbusFramer -from pymodbus.framer.old_framer_rtu import ModbusRtuFramer -from pymodbus.framer.old_framer_socket import ModbusSocketFramer -from pymodbus.framer.old_framer_tls import ModbusTlsFramer +from pymodbus.framer.base import FramerBase, FramerType from pymodbus.framer.rtu import FramerRTU from pymodbus.framer.socket import FramerSocket from pymodbus.framer.tls import FramerTLS -FRAMER_NAME_TO_OLD_CLASS = { - FramerType.ASCII: ModbusAsciiFramer, - FramerType.RTU: ModbusRtuFramer, - FramerType.SOCKET: ModbusSocketFramer, - FramerType.TLS: ModbusTlsFramer, -} FRAMER_NAME_TO_CLASS = { FramerType.ASCII: FramerAscii, FramerType.RTU: FramerRTU, diff --git a/pymodbus/framer/base.py b/pymodbus/framer/base.py index 889176c17..490559853 100644 --- a/pymodbus/framer/base.py +++ b/pymodbus/framer/base.py @@ -6,7 +6,7 @@ """ from __future__ import annotations -from abc import abstractmethod +from enum import Enum from pymodbus.exceptions import ModbusIOException from pymodbus.factory import ClientDecoder, ServerDecoder @@ -14,6 +14,15 @@ from pymodbus.pdu import ModbusRequest, ModbusResponse +class FramerType(str, Enum): + """Type of Modbus frame.""" + + ASCII = "ascii" + RTU = "rtu" + SOCKET = "socket" + TLS = "tls" + + class FramerBase: """Intern base.""" @@ -31,6 +40,7 @@ def __init__( self.incoming_dev_id = 0 self.incoming_tid = 0 self.databuffer = b"" + self.message_handler = self def decode(self, data: bytes) -> tuple[int, bytes]: """Decode ADU. @@ -48,7 +58,6 @@ def decode(self, data: bytes) -> tuple[int, bytes]: self.incoming_tid = 0 return used_len, res_data - @abstractmethod def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: """Decode ADU. @@ -56,15 +65,16 @@ def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: used_len (int) or 0 to read more modbus request/response (bytes) """ + return data_len, data - @abstractmethod - def encode(self, pdu: bytes, dev_id: int, tid: int) -> bytes: + def encode(self, pdu: bytes, _dev_id: int, _tid: int) -> bytes: """Encode ADU. returns: modbus ADU (bytes) """ + return pdu def buildPacket(self, message: ModbusRequest | ModbusResponse) -> bytes: """Create a ready to send modbus packet. @@ -93,6 +103,7 @@ def processIncomingPacket(self, data: bytes, callback, slave, tid=None): return if not isinstance(slave, (list, tuple)): slave = [slave] + self.dev_ids = slave while True: if self.databuffer == b'': return diff --git a/pymodbus/framer/framer.py b/pymodbus/framer/framer.py deleted file mode 100644 index 52eccf1de..000000000 --- a/pymodbus/framer/framer.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Framing layer. - -The framer layer is responsible for isolating/generating the request/request from -the frame (prefix - postfix) - -According to the selected type of modbus frame a prefix/suffix is added/removed - -This layer is also responsible for discarding invalid frames and frames for other slaves. -""" -from __future__ import annotations - -from abc import abstractmethod -from enum import Enum - -from pymodbus.factory import ClientDecoder, ServerDecoder -from pymodbus.framer.ascii import FramerAscii -from pymodbus.framer.rtu import FramerRTU -from pymodbus.framer.socket import FramerSocket -from pymodbus.framer.tls import FramerTLS -from pymodbus.transport.transport import CommParams, ModbusProtocol - - -class FramerType(str, Enum): - """Type of Modbus frame.""" - - ASCII = "ascii" - RTU = "rtu" - SOCKET = "socket" - TLS = "tls" - - -class AsyncFramer(ModbusProtocol): - """Framer layer extending transport layer. - - extends the ModbusProtocol to handle receiving and sending of complete modbus PDU. - - When receiving: - - Secures full valid Modbus PDU is received (across multiple callbacks) - - Validates and removes Modbus prefix/suffix (CRC for serial, MBAP for others) - - Callback with pure request/response - - Skips invalid messagees - - Hunt for valid message (RTU type) - - When sending: - - Add prefix/suffix to request/response (CRC for serial, MBAP for others) - - Call transport to send - - The class is designed to take care of differences between the modbus message types, - and provide a neutral interface with pure requests/responses to/from the upper layers. - """ - - def __init__(self, - framer_type: FramerType, - params: CommParams, - is_server: bool, - decoder: ClientDecoder | ServerDecoder, - device_ids: list[int], - ): - """Initialize a framer instance. - - :param framer_type: Modbus message type - :param params: parameter dataclass - :param is_server: true if object act as a server (listen/connect) - :param device_ids: list of device id to accept, 0 in list means broadcast. - """ - super().__init__(params, is_server) - self.device_ids = device_ids - self.broadcast: bool = (0 in device_ids) - - self.handle = { - FramerType.ASCII: FramerAscii(decoder, device_ids), - FramerType.RTU: FramerRTU(decoder, device_ids), - FramerType.SOCKET: FramerSocket(decoder, device_ids), - FramerType.TLS: FramerTLS(decoder, device_ids), - }[framer_type] - - - def callback_data(self, data: bytes, addr: tuple | None = None) -> int: - """Handle received data.""" - tot_len = 0 - buf_len = len(data) - while True: - used_len, msg = self.handle.decode(data[tot_len:]) - tot_len += used_len - if msg: - if self.broadcast or self.handle.incoming_dev_id in self.device_ids: - self.callback_request_response(msg, self.handle.incoming_dev_id, self.handle.incoming_tid) - if tot_len == buf_len: - return tot_len - else: - return tot_len - - @abstractmethod - def callback_request_response(self, data: bytes, device_id: int, tid: int) -> None: - """Handle received modbus request/response.""" - - def build_send(self, data: bytes, device_id: int, tid: int, addr: tuple | None = None) -> None: - """Send request/response. - - :param data: non-empty bytes object with data to send. - :param device_id: device identifier (slave/unit) - :param tid: transaction id (0 if not used). - :param addr: optional addr, only used for UDP server. - """ - send_data = self.handle.encode(data, device_id, tid) - self.send(send_data, addr) diff --git a/pymodbus/framer/old_framer_ascii.py b/pymodbus/framer/old_framer_ascii.py deleted file mode 100644 index 0ca277e0c..000000000 --- a/pymodbus/framer/old_framer_ascii.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Ascii_framer.""" -from pymodbus.framer.old_framer_base import BYTE_ORDER, FRAME_HEADER, ModbusFramer - -from .ascii import FramerAscii - - -ASCII_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER - - -# --------------------------------------------------------------------------- # -# Modbus ASCII olf framer -# --------------------------------------------------------------------------- # -class ModbusAsciiFramer(ModbusFramer): - r"""Modbus ASCII Frame Controller. - - [ Start ][Address ][ Function ][ Data ][ LRC ][ End ] - 1c 2c 2c Nc 2c 2c - - * data can be 0 - 2x252 chars - * end is "\\r\\n" (Carriage return line feed), however the line feed - character can be changed via a special command - * start is ":" - - This framer is used for serial transmission. Unlike the RTU protocol, - the data in this framer is transferred in plain text ascii. - """ - - def __init__(self, decoder, client=None): - """Initialize a new instance of the framer. - - :param decoder: The decoder implementation to use - """ - super().__init__(decoder, client, FramerAscii) diff --git a/pymodbus/framer/old_framer_base.py b/pymodbus/framer/old_framer_base.py deleted file mode 100644 index c1cc32d0e..000000000 --- a/pymodbus/framer/old_framer_base.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Framer start.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pymodbus.factory import ClientDecoder, ServerDecoder -from pymodbus.framer.base import FramerBase -from pymodbus.pdu import ModbusRequest, ModbusResponse - - -if TYPE_CHECKING: - pass - -# Unit ID, Function Code -BYTE_ORDER = ">" -FRAME_HEADER = "BB" - -# Transaction Id, Protocol ID, Length, Unit ID, Function Code -SOCKET_FRAME_HEADER = BYTE_ORDER + "HHH" + FRAME_HEADER - -# Function Code -TLS_FRAME_HEADER = BYTE_ORDER + "B" - - -class ModbusFramer: - """Base Framer class.""" - - def __init__( - self, - decoder: ClientDecoder | ServerDecoder, - _client, - new_framer, - ) -> None: - """Initialize a new instance of the framer. - - :param decoder: The decoder implementation to use - """ - self.message_handler: FramerBase = new_framer(decoder, [0]) - - @property - def incoming_dev_id(self) -> int: - """Return dev id.""" - return self.message_handler.incoming_dev_id - - @property - def incoming_tid(self) -> int: - """Return tid.""" - return self.message_handler.incoming_tid - - def processIncomingPacket(self, data: bytes, callback, slave, tid=None): - """Process new packet pattern.""" - self.message_handler.processIncomingPacket(data, callback, slave, tid=tid) - - def buildPacket(self, message: ModbusRequest | ModbusResponse) -> bytes: - """Create a ready to send modbus packet.""" - return self.message_handler.buildPacket(message) diff --git a/pymodbus/framer/old_framer_rtu.py b/pymodbus/framer/old_framer_rtu.py deleted file mode 100644 index 5a5efc430..000000000 --- a/pymodbus/framer/old_framer_rtu.py +++ /dev/null @@ -1,51 +0,0 @@ -"""RTU framer.""" - -from pymodbus.framer.old_framer_base import BYTE_ORDER, FRAME_HEADER, ModbusFramer -from pymodbus.framer.rtu import FramerRTU - - -RTU_FRAME_HEADER = BYTE_ORDER + FRAME_HEADER - - -# --------------------------------------------------------------------------- # -# Modbus RTU old Framer -# --------------------------------------------------------------------------- # -class ModbusRtuFramer(ModbusFramer): - """Modbus RTU Frame controller. - - [ Start Wait ] [Address ][ Function Code] [ Data ][ CRC ][ End Wait ] - 3.5 chars 1b 1b Nb 2b 3.5 chars - - Wait refers to the amount of time required to transmit at least x many - characters. In this case it is 3.5 characters. Also, if we receive a - wait of 1.5 characters at any point, we must trigger an error message. - Also, it appears as though this message is little endian. The logic is - simplified as the following:: - - block-on-read: - read until 3.5 delay - check for errors - decode - - The following table is a listing of the baud wait times for the specified - baud rates:: - - ------------------------------------------------------------------ - Baud 1.5c (18 bits) 3.5c (38 bits) - ------------------------------------------------------------------ - 1200 13333.3 us 31666.7 us - 4800 3333.3 us 7916.7 us - 9600 1666.7 us 3958.3 us - 19200 833.3 us 1979.2 us - 38400 416.7 us 989.6 us - ------------------------------------------------------------------ - 1 Byte = start + 8 bits + parity + stop = 11 bits - (1/Baud)(bits) = delay seconds - """ - - def __init__(self, decoder, client=None): - """Initialize a new instance of the framer. - - :param decoder: The decoder factory implementation to use - """ - super().__init__(decoder, client, FramerRTU) diff --git a/pymodbus/framer/old_framer_socket.py b/pymodbus/framer/old_framer_socket.py deleted file mode 100644 index 75a394e7e..000000000 --- a/pymodbus/framer/old_framer_socket.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Socket framer.""" - -from pymodbus.framer.old_framer_base import ModbusFramer -from pymodbus.framer.socket import FramerSocket - - -# --------------------------------------------------------------------------- # -# Modbus TCP old framer -# --------------------------------------------------------------------------- # - - -class ModbusSocketFramer(ModbusFramer): - """Modbus Socket Frame controller. - - Before each modbus TCP message is an MBAP header which is used as a - message frame. It allows us to easily separate messages as follows:: - - [ MBAP Header ] [ Function Code] [ Data ] \ - [ tid ][ pid ][ length ][ uid ] - 2b 2b 2b 1b 1b Nb - - while len(message) > 0: - tid, pid, length`, uid = struct.unpack(">HHHB", message) - request = message[0:7 + length - 1`] - message = [7 + length - 1:] - - * length = uid + function code + data - * The -1 is to account for the uid byte - """ - - def __init__(self, decoder, client=None): - """Initialize a new instance of the framer. - - :param decoder: The decoder factory implementation to use - """ - super().__init__(decoder, client, FramerSocket) diff --git a/pymodbus/framer/old_framer_tls.py b/pymodbus/framer/old_framer_tls.py deleted file mode 100644 index ff13a6c1c..000000000 --- a/pymodbus/framer/old_framer_tls.py +++ /dev/null @@ -1,28 +0,0 @@ -"""TLS framer.""" - -from pymodbus.framer.old_framer_base import ModbusFramer -from pymodbus.framer.tls import FramerTLS - - -# --------------------------------------------------------------------------- # -# Modbus TLS old framer -# --------------------------------------------------------------------------- # - - -class ModbusTlsFramer(ModbusFramer): - """Modbus TLS Frame controller. - - No prefix MBAP header before decrypted PDU is used as a message frame for - Modbus Security Application Protocol. It allows us to easily separate - decrypted messages which is PDU as follows: - - [ Function Code] [ Data ] - 1b Nb - """ - - def __init__(self, decoder, client=None): - """Initialize a new instance of the framer. - - :param decoder: The decoder factory implementation to use - """ - super().__init__(decoder, client, FramerTLS) diff --git a/pymodbus/server/async_io.py b/pymodbus/server/async_io.py index 40aaca12d..378b8cd49 100644 --- a/pymodbus/server/async_io.py +++ b/pymodbus/server/async_io.py @@ -11,7 +11,7 @@ from pymodbus.device import ModbusControlBlock, ModbusDeviceIdentification from pymodbus.exceptions import NoSuchSlaveException from pymodbus.factory import ServerDecoder -from pymodbus.framer import FRAMER_NAME_TO_OLD_CLASS, FramerType, ModbusFramer +from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerBase, FramerType from pymodbus.logging import Log from pymodbus.pdu import ModbusExceptions as merror from pymodbus.transport import CommParams, CommType, ModbusProtocol @@ -48,7 +48,7 @@ def __init__(self, owner): self.running = False self.receive_queue: asyncio.Queue = asyncio.Queue() self.handler_task = None # coroutine to be run on asyncio loop - self.framer: ModbusFramer + self.framer: FramerBase self.loop = asyncio.get_running_loop() def _log_exception(self): @@ -68,7 +68,7 @@ def callback_connected(self) -> None: self.running = True self.framer = self.server.framer( self.server.decoder, - client=None, + [0], ) # schedule the connection handler on the event loop @@ -271,7 +271,7 @@ def __init__( if isinstance(identity, ModbusDeviceIdentification): self.control.Identity.update(identity) - self.framer = FRAMER_NAME_TO_OLD_CLASS.get(framer, framer) + self.framer = FRAMER_NAME_TO_CLASS[framer] self.serving: asyncio.Future = asyncio.Future() def callback_new_connection(self): @@ -505,7 +505,7 @@ def __init__( If the identity structure is not passed in, the ModbusControlBlock uses its own empty structure. :param context: The ModbusServerContext datastore - :param framer: The framer strategy to use, default ModbusRtuFramer + :param framer: The framer strategy to use, default FramerType.RTU :param identity: An optional identify structure :param port: The serial port to attach to :param stopbits: The number of stop bits to use diff --git a/pymodbus/transaction.py b/pymodbus/transaction.py index 004f42ae6..f4d540e7a 100644 --- a/pymodbus/transaction.py +++ b/pymodbus/transaction.py @@ -4,10 +4,6 @@ __all__ = [ "ModbusTransactionManager", - "ModbusSocketFramer", - "ModbusTlsFramer", - "ModbusRtuFramer", - "ModbusAsciiFramer", "SyncModbusTransactionManager", ] @@ -22,10 +18,10 @@ ModbusIOException, ) from pymodbus.framer import ( - ModbusAsciiFramer, - ModbusRtuFramer, - ModbusSocketFramer, - ModbusTlsFramer, + FramerAscii, + FramerRTU, + FramerSocket, + FramerTLS, ) from pymodbus.logging import Log from pymodbus.pdu import ModbusRequest @@ -146,13 +142,13 @@ def __init__(self, client: ModbusBaseSyncClient, retries): def _set_adu_size(self): """Set adu size.""" # base ADU size of modbus frame in bytes - if isinstance(self.client.framer, ModbusSocketFramer): + if isinstance(self.client.framer, FramerSocket): self.base_adu_size = 7 # tid(2), pid(2), length(2), uid(1) - elif isinstance(self.client.framer, ModbusRtuFramer): + elif isinstance(self.client.framer, FramerRTU): self.base_adu_size = 3 # address(1), CRC(2) - elif isinstance(self.client.framer, ModbusAsciiFramer): + elif isinstance(self.client.framer, FramerAscii): self.base_adu_size = 7 # start(1)+ Address(2), LRC(2) + end(2) - elif isinstance(self.client.framer, ModbusTlsFramer): + elif isinstance(self.client.framer, FramerTLS): self.base_adu_size = 0 # no header and footer else: self.base_adu_size = -1 @@ -165,11 +161,11 @@ def _calculate_response_length(self, expected_pdu_size): def _calculate_exception_length(self): """Return the length of the Modbus Exception Response according to the type of Framer.""" - if isinstance(self.client.framer, (ModbusSocketFramer, ModbusTlsFramer)): + if isinstance(self.client.framer, (FramerSocket, FramerTLS)): return self.base_adu_size + 2 # Fcode(1), ExceptionCode(1) - if isinstance(self.client.framer, ModbusAsciiFramer): + if isinstance(self.client.framer, FramerAscii): return self.base_adu_size + 4 # Fcode(2), ExceptionCode(2) - if isinstance(self.client.framer, ModbusRtuFramer): + if isinstance(self.client.framer, FramerRTU): return self.base_adu_size + 2 # Fcode(1), ExceptionCode(1) return None @@ -197,10 +193,10 @@ def execute(self, request: ModbusRequest): # noqa: C901 self.client.framer.message_handler.databuffer = b'' broadcast = not request.slave_id expected_response_length = None - if not isinstance(self.client.framer, ModbusSocketFramer): + if not isinstance(self.client.framer, FramerSocket): if hasattr(request, "get_response_pdu_size"): response_pdu_size = request.get_response_pdu_size() - if isinstance(self.client.framer, ModbusAsciiFramer): + if isinstance(self.client.framer, FramerAscii): response_pdu_size *= 2 if response_pdu_size: expected_response_length = ( @@ -346,11 +342,11 @@ def _recv(self, expected_response_length, full) -> bytes: # noqa: C901 total = None if not full: exception_length = self._calculate_exception_length() - if isinstance(self.client.framer, ModbusSocketFramer): + if isinstance(self.client.framer, FramerSocket): min_size = 8 - elif isinstance(self.client.framer, ModbusRtuFramer): + elif isinstance(self.client.framer, FramerRTU): min_size = 4 - elif isinstance(self.client.framer, ModbusAsciiFramer): + elif isinstance(self.client.framer, FramerAscii): min_size = 5 else: min_size = expected_response_length @@ -363,21 +359,21 @@ def _recv(self, expected_response_length, full) -> bytes: # noqa: C901 f"({len(read_min)} received)" ) if read_min: - if isinstance(self.client.framer, ModbusSocketFramer): + if isinstance(self.client.framer, FramerSocket): func_code = int(read_min[-1]) - elif isinstance(self.client.framer, ModbusRtuFramer): + elif isinstance(self.client.framer, FramerRTU): func_code = int(read_min[1]) - elif isinstance(self.client.framer, ModbusAsciiFramer): + elif isinstance(self.client.framer, FramerAscii): func_code = int(read_min[3:5], 16) else: func_code = -1 if func_code < 0x80: # Not an error - if isinstance(self.client.framer, ModbusSocketFramer): + if isinstance(self.client.framer, FramerSocket): length = struct.unpack(">H", read_min[4:6])[0] - 1 expected_response_length = 7 + length elif expected_response_length is None and isinstance( - self.client.framer, ModbusRtuFramer + self.client.framer, FramerRTU ): with suppress( IndexError # response length indeterminate with available bytes diff --git a/test/framers/conftest.py b/test/framers/conftest.py index 851c2e69c..fa157404f 100644 --- a/test/framers/conftest.py +++ b/test/framers/conftest.py @@ -1,13 +1,10 @@ """Configure pytest.""" from __future__ import annotations -from unittest import mock - import pytest from pymodbus.factory import ClientDecoder, ServerDecoder -from pymodbus.framer import FRAMER_NAME_TO_CLASS, AsyncFramer, FramerType -from pymodbus.transport import CommParams +from pymodbus.framer import FRAMER_NAME_TO_CLASS, FramerType @pytest.fixture(name="entry") @@ -32,22 +29,3 @@ async def prepare_test_framer(entry, is_server, dev_ids): (ServerDecoder if is_server else ClientDecoder)(), dev_ids, ) - - - - - -@mock.patch.multiple(AsyncFramer, __abstractmethods__=set()) # eliminate abstract methods (callbacks) -@pytest.fixture(name="dummy_async_framer") -async def prepare_test_async_framer(entry, is_server): - """Return framer object.""" - decoder = (ServerDecoder if is_server else ClientDecoder)() - framer = AsyncFramer(entry, CommParams(), is_server, decoder, [0, 1]) # type: ignore[abstract] - framer.send = mock.Mock() # type: ignore[method-assign] - #if entry == FramerType.RTU: - #func_table = decoder.lookup # type: ignore[attr-defined] - #for key, ent in func_table.items(): - # fix_len = getattr(ent, "_rtu_frame_size", 0) - # cnt_pos = getattr(ent, "_rtu_byte_count_pos", 0) - # framer.handle.set_fc_calc(key, fix_len, cnt_pos) - return framer diff --git a/test/framers/generator.py b/test/framers/generator.py index 04344a823..37e4e46c8 100755 --- a/test/framers/generator.py +++ b/test/framers/generator.py @@ -3,10 +3,10 @@ from pymodbus.factory import ClientDecoder, ServerDecoder from pymodbus.framer import ( - ModbusAsciiFramer, - ModbusRtuFramer, - ModbusSocketFramer, - ModbusTlsFramer, + FramerAscii, + FramerRTU, + FramerSocket, + FramerTLS, ) from pymodbus.pdu import ModbusExceptions as merror from pymodbus.pdu.register_read_message import ( @@ -17,19 +17,19 @@ def set_calls(): """Define calls.""" - for framer in (ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer, ModbusTlsFramer): + for framer in (FramerAscii, FramerRTU, FramerSocket, FramerTLS): print(f"framer --> {framer}") for dev_id in (0, 17, 255): print(f" dev_id --> {dev_id}") for tid in (0, 3077): print(f" tid --> {tid}") - client = framer(ClientDecoder()) + client = framer(ClientDecoder(), [0]) request = ReadHoldingRegistersRequest(124, 2, dev_id) request.transaction_id = tid result = client.buildPacket(request) print(f" request --> {result}") print(f" request --> {result.hex()}") - server = framer(ServerDecoder()) + server = framer(ServerDecoder(), [0]) response = ReadHoldingRegistersResponse([141,142]) response.slave_id = dev_id response.transaction_id = tid diff --git a/test/framers/test_asyncframer.py b/test/framers/test_asyncframer.py deleted file mode 100644 index b05663e40..000000000 --- a/test/framers/test_asyncframer.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Test framer.""" - -from unittest import mock - -import pytest - -from pymodbus.factory import ClientDecoder -from pymodbus.framer import FramerType -from pymodbus.framer.ascii import FramerAscii -from pymodbus.framer.rtu import FramerRTU -from pymodbus.framer.socket import FramerSocket -from pymodbus.framer.tls import FramerTLS - - -class TestFramer: - """Test module.""" - - @pytest.mark.parametrize(("entry"), list(FramerType)) - async def test_framer_init(self, dummy_async_framer): - """Test framer type.""" - assert dummy_async_framer.handle - - @pytest.mark.parametrize(("data", "res_len", "cx", "rc"), [ - (b'12345', 5, 1, [(5, b'12345')]), # full frame - (b'12345', 0, 0, [(0, b'')]), # not full frame, need more data - (b'12345', 5, 0, [(5, b'')]), # faulty frame, skipped - (b'1234512345', 10, 2, [(5, b'12345'), (5, b'12345')]), # 2 full frames - (b'12345678', 5, 1, [(5, b'12345'), (0, b'')]), # full frame, not full frame - (b'67812345', 8, 1, [(8, b'12345')]), # garble first, full frame next - (b'12345678', 5, 0, [(5, b'')]), # garble first, not full frame - (b'12345678', 8, 0, [(8, b'')]), # garble first, faulty frame - ]) - async def test_framer_callback(self, dummy_async_framer, data, res_len, cx, rc): - """Test framer type.""" - dummy_async_framer.callback_request_response = mock.Mock() - dummy_async_framer.handle.decode = mock.MagicMock(side_effect=iter(rc)) - assert dummy_async_framer.callback_data(data) == res_len - assert dummy_async_framer.callback_request_response.call_count == cx - if cx: - dummy_async_framer.callback_request_response.assert_called_with(b'12345', 0, 0) - else: - dummy_async_framer.callback_request_response.assert_not_called() - - @pytest.mark.parametrize(("data", "res_len", "rc"), [ - (b'12345', 5, [(5, b'12345'), (0, b'')]), # full frame, wrong dev_id - ]) - async def test_framer_callback_wrong_id(self, dummy_async_framer, data, res_len, rc): - """Test framer type.""" - dummy_async_framer.callback_request_response = mock.Mock() - dummy_async_framer.handle.decode = mock.MagicMock(side_effect=iter(rc)) - dummy_async_framer.broadcast = False - assert dummy_async_framer.callback_data(data) == res_len - # dummy_async_framer.callback_request_response.assert_not_called() - - async def test_framer_build_send(self, dummy_async_framer): - """Test framer type.""" - dummy_async_framer.handle.encode = mock.MagicMock(return_value=(b'decode')) - dummy_async_framer.build_send(b'decode', 1, 0) - dummy_async_framer.handle.encode.assert_called_once() - dummy_async_framer.send.assert_called_once() - dummy_async_framer.send.assert_called_with(b'decode', None) - - @pytest.mark.parametrize( - ("data", "res_len", "res_id", "res_tid", "res_data"), [ - (b'\x00\x01', 0, 0, 0, b''), - (b'\x01\x02\x03', 3, 1, 2, b'\x03'), - (b'\x04\x05\x06\x07\x08\x09\x00\x01\x02\x03', 10, 4, 5, b'\x06\x07\x08\x09\x00\x01\x02\x03'), - ]) - async def xtest_framer_decode(self, dummy_async_framer, data, res_id, res_tid, res_len, res_data): - """Test decode method in all types.""" - t_len, t_id, t_tid, t_data = dummy_async_framer.handle.decode(data) - assert res_len == t_len - assert res_id == t_id - assert res_tid == t_tid - assert res_data == t_data - - @pytest.mark.parametrize( - ("data", "dev_id", "tr_id", "res_data"), [ - (b'\x01\x02', 5, 6, b'\x05\x06\x01\x02'), - (b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09', 17, 25, b'\x11\x19\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09'), - ]) - async def xtest_framer_encode(self, dummy_async_framer, data, dev_id, tr_id, res_data): - """Test decode method in all types.""" - t_data = dummy_async_framer.handle.encode(data, dev_id, tr_id) - assert res_data == t_data - - @pytest.mark.parametrize( - ("func", "test_compare", "expect"), - [(FramerAscii.check_LRC, 0x1c, True), - (FramerAscii.check_LRC, 0x0c, False), - (FramerAscii.compute_LRC, None, 0x1c), - (FramerRTU.check_CRC, 0xE2DB, True), - (FramerRTU.check_CRC, 0xDBE2, False), - (FramerRTU.compute_CRC, None, 0xE2DB), - ] - ) - def test_LRC_CRC(self, func, test_compare, expect): - """Test check_LRC.""" - data = b'\x12\x34\x23\x45\x34\x56\x45\x67' - assert expect == func(data, test_compare) if test_compare else func(data) - - def test_roundtrip_LRC(self): - """Test combined compute/check LRC.""" - data = b'\x12\x34\x23\x45\x34\x56\x45\x67' - assert FramerAscii.compute_LRC(data) == 0x1c - assert FramerAscii.check_LRC(data, 0x1C) - - def test_crc16_table(self): - """Test the crc16 table is prefilled.""" - assert len(FramerRTU.crc16_table) == 256 - assert isinstance(FramerRTU.crc16_table[0], int) - assert isinstance(FramerRTU.crc16_table[255], int) - - def test_roundtrip_CRC(self): - """Test combined compute/check CRC.""" - data = b'\x12\x34\x23\x45\x34\x56\x45\x67' - assert FramerRTU.compute_CRC(data) == 0xE2DB - assert FramerRTU.check_CRC(data, 0xE2DB) - - - -class TestFramerType: - """Test classes.""" - - @pytest.mark.parametrize( - ("frame", "frame_expected"), - [ - (FramerAscii, [ - b':0003007C00027F\r\n', - b':000304008D008EDE\r\n', - b':0083027B\r\n', - b':1103007C00026E\r\n', - b':110304008D008ECD\r\n', - b':1183026A\r\n', - b':FF03007C000280\r\n', - b':FF0304008D008EDF\r\n', - b':FF83027C\r\n', - b':0003007C00027F\r\n', - b':000304008D008EDE\r\n', - b':0083027B\r\n', - b':1103007C00026E\r\n', - b':110304008D008ECD\r\n', - b':1183026A\r\n', - b':FF03007C000280\r\n', - b':FF0304008D008EDF\r\n', - b':FF83027C\r\n', - b':0003007C00027F\r\n', - b':000304008D008EDE\r\n', - b':0083027B\r\n', - b':1103007C00026E\r\n', - b':110304008D008ECD\r\n', - b':1183026A\r\n', - b':FF03007C000280\r\n', - b':FF0304008D008EDF\r\n', - b':FF83027C\r\n', - ]), - (FramerRTU, [ - b'\x00\x03\x00\x7c\x00\x02\x04\x02', - b'\x00\x03\x04\x00\x8d\x00\x8e\xfa\xbc', - b'\x00\x83\x02\x91\x31', - b'\x11\x03\x00\x7c\x00\x02\x07\x43', - b'\x11\x03\x04\x00\x8d\x00\x8e\xfb\xbd', - b'\x11\x83\x02\xc1\x34', - b'\xff\x03\x00\x7c\x00\x02\x10\x0d', - b'\xff\x03\x04\x00\x8d\x00\x8e\xf5\xb3', - b'\xff\x83\x02\xa1\x01', - b'\x00\x03\x00\x7c\x00\x02\x04\x02', - b'\x00\x03\x04\x00\x8d\x00\x8e\xfa\xbc', - b'\x00\x83\x02\x91\x31', - b'\x11\x03\x00\x7c\x00\x02\x07\x43', - b'\x11\x03\x04\x00\x8d\x00\x8e\xfb\xbd', - b'\x11\x83\x02\xc1\x34', - b'\xff\x03\x00\x7c\x00\x02\x10\x0d', - b'\xff\x03\x04\x00\x8d\x00\x8e\xf5\xb3', - b'\xff\x83\x02\xa1\x01', - b'\x00\x03\x00\x7c\x00\x02\x04\x02', - b'\x00\x03\x04\x00\x8d\x00\x8e\xfa\xbc', - b'\x00\x83\x02\x91\x31', - b'\x11\x03\x00\x7c\x00\x02\x07\x43', - b'\x11\x03\x04\x00\x8d\x00\x8e\xfb\xbd', - b'\x11\x83\x02\xc1\x34', - b'\xff\x03\x00\x7c\x00\x02\x10\x0d', - b'\xff\x03\x04\x00\x8d\x00\x8e\xf5\xb3', - b'\xff\x83\x02\xa1\x01', - ]), - (FramerSocket, [ - b'\x00\x00\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', - b'\x00\x00\x00\x00\x00\x07\x00\x03\x04\x00\x8d\x00\x8e', - b'\x00\x00\x00\x00\x00\x03\x00\x83\x02', - b'\x00\x00\x00\x00\x00\x06\x11\x03\x00\x7c\x00\x02', - b'\x00\x00\x00\x00\x00\x07\x11\x03\x04\x00\x8d\x00\x8e', - b'\x00\x00\x00\x00\x00\x03\x11\x83\x02', - b'\x00\x00\x00\x00\x00\x06\xff\x03\x00\x7c\x00\x02', - b'\x00\x00\x00\x00\x00\x07\xff\x03\x04\x00\x8d\x00\x8e', - b'\x00\x00\x00\x00\x00\x03\xff\x83\x02', - b'\x0c\x05\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', - b'\x0c\x05\x00\x00\x00\x07\x00\x03\x04\x00\x8d\x00\x8e', - b'\x0c\x05\x00\x00\x00\x03\x00\x83\x02', - b'\x0c\x05\x00\x00\x00\x06\x11\x03\x00\x7c\x00\x02', - b'\x0c\x05\x00\x00\x00\x07\x11\x03\x04\x00\x8d\x00\x8e', - b'\x0c\x05\x00\x00\x00\x03\x11\x83\x02', - b'\x0c\x05\x00\x00\x00\x06\xff\x03\x00\x7c\x00\x02', - b'\x0c\x05\x00\x00\x00\x07\xff\x03\x04\x00\x8d\x00\x8e', - b'\x0c\x05\x00\x00\x00\x03\xff\x83\x02', - ]), - (FramerTLS, [ - b'\x03\x00\x7c\x00\x02', - b'\x03\x04\x00\x8d\x00\x8e', - b'\x83\x02', - ]), - ] - ) - @pytest.mark.parametrize( - ("inx1", "data"), - [ - (0, b"\x03\x00\x7c\x00\x02",), # Request - (1, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (2, b'\x83\x02',), # Exception - ] - ) - @pytest.mark.parametrize( - ("inx2", "dev_id"), - [ - (0, 0), - (3, 17), - (6, 255), - ] - ) - @pytest.mark.parametrize( - ("inx3", "tr_id"), - [ - (0, 0), - (9, 3077), - ] - ) - def test_encode_type(self, frame, frame_expected, data, dev_id, tr_id, inx1, inx2, inx3): - """Test encode method.""" - if frame == FramerTLS and dev_id + tr_id: - return - frame_obj = frame(ClientDecoder(), [0]) - expected = frame_expected[inx1 + inx2 + inx3] - encoded_data = frame_obj.encode(data, dev_id, tr_id) - assert encoded_data == expected - - @pytest.mark.parametrize( - ("entry", "is_server", "data", "dev_id", "tr_id", "expected"), - [ - (FramerType.ASCII, True, b':0003007C00027F\r\n', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.ASCII, False, b':000304008D008EDE\r\n', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.ASCII, False, b':0083027B\r\n', 0, 0, b'\x83\x02',), # Exception - (FramerType.ASCII, True, b':1103007C00026E\r\n', 17, 17, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.ASCII, False, b':110304008D008ECD\r\n', 17, 17, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.ASCII, False, b':1183026A\r\n', 17, 17, b'\x83\x02',), # Exception - (FramerType.ASCII, True, b':FF03007C000280\r\n', 255, 255, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.ASCII, False, b':FF0304008D008EDF\r\n', 255, 255, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.ASCII, False, b':FF83027C\r\n', 255, 255, b'\x83\x02',), # Exception - (FramerType.RTU, True, b'\x00\x03\x00\x7c\x00\x02\x04\x02', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.RTU, False, b'\x00\x03\x04\x00\x8d\x00\x8e\xfa\xbc', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.RTU, False, b'\x00\x83\x02\x91\x31', 0, 0, b'\x83\x02',), # Exception - (FramerType.RTU, True, b'\x11\x03\x00\x7c\x00\x02\x07\x43', 17, 17, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.RTU, False, b'\x11\x03\x04\x00\x8d\x00\x8e\xfb\xbd', 17, 17, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.RTU, False, b'\x11\x83\x02\xc1\x34', 17, 17, b'\x83\x02',), # Exception - (FramerType.RTU, True, b'\xff\x03\x00|\x00\x02\x10\x0d', 255, 255, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.RTU, False, b'\xff\x03\x04\x00\x8d\x00\x8e\xf5\xb3', 255, 255, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.RTU, False, b'\xff\x83\x02\xa1\x01', 255, 255, b'\x83\x02',), # Exception - (FramerType.SOCKET, True, b'\x00\x00\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x07\x00\x03\x04\x00\x8d\x00\x8e', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x03\x00\x83\x02', 0, 0, b'\x83\x02',), # Exception - (FramerType.SOCKET, True, b'\x00\x00\x00\x00\x00\x06\x11\x03\x00\x7c\x00\x02', 17, 0, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x07\x11\x03\x04\x00\x8d\x00\x8e', 17, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x03\x11\x83\x02', 17, 0, b'\x83\x02',), # Exception - (FramerType.SOCKET, True, b'\x00\x00\x00\x00\x00\x06\xff\x03\x00\x7c\x00\x02', 255, 0, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x07\xff\x03\x04\x00\x8d\x00\x8e', 255, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.SOCKET, False, b'\x00\x00\x00\x00\x00\x03\xff\x83\x02', 255, 0, b'\x83\x02',), # Exception - (FramerType.SOCKET, True, b'\x0c\x05\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', 0, 3077, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x07\x00\x03\x04\x00\x8d\x00\x8e', 0, 3077, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x03\x00\x83\x02', 0, 3077, b'\x83\x02',), # Exception - (FramerType.SOCKET, True, b'\x0c\x05\x00\x00\x00\x06\x11\x03\x00\x7c\x00\x02', 17, 3077, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x07\x11\x03\x04\x00\x8d\x00\x8e', 17, 3077, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x03\x11\x83\x02', 17, 3077, b'\x83\x02',), # Exception - (FramerType.SOCKET, True, b'\x0c\x05\x00\x00\x00\x06\xff\x03\x00\x7c\x00\x02', 255, 3077, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x07\xff\x03\x04\x00\x8d\x00\x8e', 255, 3077, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.SOCKET, False, b'\x0c\x05\x00\x00\x00\x03\xff\x83\x02', 255, 3077, b'\x83\x02',), # Exception - (FramerType.TLS, True, b'\x03\x00\x7c\x00\x02', 0, 0, b"\x03\x00\x7c\x00\x02",), # Request - (FramerType.TLS, False, b'\x03\x04\x00\x8d\x00\x8e', 0, 0, b"\x03\x04\x00\x8d\x00\x8e",), # Response - (FramerType.TLS, False, b'\x83\x02', 0, 0, b'\x83\x02',), # Exception - ] - ) - @pytest.mark.parametrize( - ("split"), - [ - "no", - "half", - "single", - ] - ) - async def test_decode_type(self, entry, dummy_async_framer, data, dev_id, tr_id, expected, split): - """Test encode method.""" - if entry == FramerType.TLS and split != "no": - return - if entry == FramerType.RTU: - return - dummy_async_framer.callback_request_response = mock.MagicMock() - if split == "no": - used_len = dummy_async_framer.callback_data(data) - elif split == "half": - split_len = int(len(data) / 2) - assert not dummy_async_framer.callback_data(data[0:split_len]) - dummy_async_framer.callback_request_response.assert_not_called() - used_len = dummy_async_framer.callback_data(data) - else: - last = len(data) - for i in range(0, last -1): - assert not dummy_async_framer.callback_data(data[0:i+1]) - dummy_async_framer.callback_request_response.assert_not_called() - used_len = dummy_async_framer.callback_data(data) - assert used_len == len(data) - dummy_async_framer.callback_request_response.assert_called_with(expected, dev_id, tr_id) - - @pytest.mark.parametrize( - ("entry", "data", "exp"), - [ - (FramerType.ASCII, b':0003007C00017F\r\n', [ # bad crc - (17, b''), - ]), - (FramerType.ASCII, b':0003007C00027F\r\n:0003007C00027F\r\n', [ # double good crc - (17, b'\x03\x00\x7c\x00\x02'), - (17, b'\x03\x00\x7c\x00\x02'), - ]), - (FramerType.ASCII, b':0003007C00017F\r\n:0003007C00027F\r\n', [ # bad crc + good CRC - (34, b'\x03\x00\x7c\x00\x02'), - ]), - (FramerType.ASCII, b'abc:0003007C00027F\r\n', [ # garble in front - (20, b'\x03\x00\x7c\x00\x02'), - ]), - (FramerType.ASCII, b':0003007C00017F\r\nabc', [ # bad crc, garble after - (17, b''), - ]), - (FramerType.ASCII, b':0003007C00017F\r\nabcdefghijkl', [ # bad crc, garble after - (29, b''), - ]), - (FramerType.ASCII, b':0003007C00027F\r\nabc', [ # good crc, garble after - (17, b'\x03\x00\x7c\x00\x02'), - ]), - (FramerType.ASCII, b':0003007C00017F\r\n:0003', [ # bad crc, part second framer - (17, b''), - ]), - (FramerType.SOCKET, b'\x00\x00\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02\x00\x00\x00\x00\x00\x06\x00\x03\x00\x7c\x00\x02', [ # double good crc - (12, b"\x03\x00\x7c\x00\x02"), - (12, b"\x03\x00\x7c\x00\x02"), - ]), - # (FramerType.RTU, b'\x00\x83\x02\x91\x21', [ # bad crc - # (5, b''), - #]), - #(FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31', [ # dummy char in stream, bad crc - # (5, b''), - #]), - # (FramerType.RTU, b'\x00\x83\x02\x91\x21\x00\x83\x02\x91\x31', [ # bad crc + good CRC - # (10, b'\x83\x02'), - #]), - #(FramerType.RTU, b'\x00\x83\x02\xf0\x91\x31\x00\x83\x02\x91\x31', [ # dummy char in stream, bad crc + good CRC - # (11, b''), - #]), - - # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # garble in front - # (FramerType.ASCII, b'abc:0003007C00027F\r\n', [ # garble in front - # (20, b'\x03\x00\x7c\x00\x02'), - # ]), - - # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # garble after - # (FramerType.ASCII, b':0003007C00017F\r\nabc', [ # bad crc, garble after - # (17, b''), - # ]), - # (FramerType.ASCII, b':0003007C00017F\r\nabcdefghijkl', [ # bad crc, garble after - # (29, b''), - # ]), - # (FramerType.ASCII, b':0003007C00027F\r\nabc', [ # good crc, garble after - # (17, b'\x03\x00\x7c\x00\x02'), - # ]), - # (FramerType.RTU, b'\x00\x83\x02\x91\x31', 0), # part second framer - # (FramerType.ASCII, b':0003007C00017F\r\n:0003', [ # bad crc, part second framer - # (17, b''), - # ]), - ] - ) - async def test_decode_complicated(self, dummy_async_framer, data, exp): - """Test encode method.""" - for ent in exp: - used_len, res_data = dummy_async_framer.handle.decode(data) - assert used_len == ent[0] - assert res_data == ent[1] diff --git a/test/framers/test_multidrop.py b/test/framers/test_multidrop.py index 9461bb2ee..ca4476306 100644 --- a/test/framers/test_multidrop.py +++ b/test/framers/test_multidrop.py @@ -3,7 +3,7 @@ import pytest -from pymodbus.framer import ModbusRtuFramer +from pymodbus.framer import FramerRTU from pymodbus.server.async_io import ServerDecoder @@ -17,7 +17,7 @@ class NotImplementedTestMultidrop: @pytest.fixture(name="framer") def fixture_framer(self): """Prepare framer.""" - return ModbusRtuFramer(ServerDecoder()) + return FramerRTU(ServerDecoder(), [0]) @pytest.fixture(name="callback") def fixture_callback(self): diff --git a/test/sub_client/test_client_faulty_response.py b/test/sub_client/test_client_faulty_response.py index 2cc4731cc..a31235953 100644 --- a/test/sub_client/test_client_faulty_response.py +++ b/test/sub_client/test_client_faulty_response.py @@ -5,7 +5,7 @@ from pymodbus.exceptions import ModbusIOException from pymodbus.factory import ClientDecoder -from pymodbus.framer import ModbusRtuFramer, ModbusSocketFramer +from pymodbus.framer import FramerRTU, FramerSocket class TestFaultyResponses: @@ -18,7 +18,7 @@ class TestFaultyResponses: @pytest.fixture(name="framer") def fixture_framer(self): """Prepare framer.""" - return ModbusSocketFramer(ClientDecoder()) + return FramerSocket(ClientDecoder(), [0]) @pytest.fixture(name="callback") def fixture_callback(self): @@ -33,7 +33,7 @@ def test_ok_frame(self, framer, callback): def test_1917_frame(self, callback): """Test invalid frame in issue 1917.""" recv = b"\x01\x86\x02\x00\x01" - framer = ModbusRtuFramer(ClientDecoder()) + framer = FramerRTU(ClientDecoder(), [0]) framer.processIncomingPacket(recv, callback, self.slaves) callback.assert_not_called() diff --git a/test/sub_client/test_client_sync.py b/test/sub_client/test_client_sync.py index 33dd1d5b0..e2dd62d8a 100755 --- a/test/sub_client/test_client_sync.py +++ b/test/sub_client/test_client_sync.py @@ -14,11 +14,11 @@ ModbusUdpClient, ) from pymodbus.exceptions import ConnectionException -from pymodbus.transaction import ( - ModbusAsciiFramer, - ModbusRtuFramer, - ModbusSocketFramer, - ModbusTlsFramer, +from pymodbus.framer import ( + FramerAscii, + FramerRTU, + FramerSocket, + FramerTLS, ) from test.conftest import mockSocket @@ -217,7 +217,7 @@ def test_syn_tls_client_instantiation(self): # default SSLContext client = ModbusTlsClient("127.0.0.1") assert client - assert isinstance(client.framer, ModbusTlsFramer) + assert isinstance(client.framer, FramerTLS) assert client.comm_params.sslctx @mock.patch("pymodbus.client.tcp.select") @@ -307,15 +307,15 @@ def test_sync_serial_client_instantiation(self): assert client assert isinstance( ModbusSerialClient("/dev/null", framer=FramerType.ASCII).framer, - ModbusAsciiFramer, + FramerAscii, ) assert isinstance( ModbusSerialClient("/dev/null", framer=FramerType.RTU).framer, - ModbusRtuFramer, + FramerRTU, ) assert isinstance( ModbusSerialClient("/dev/null", framer=FramerType.SOCKET).framer, - ModbusSocketFramer, + FramerSocket, ) def test_sync_serial_rtu_client_timeouts(self): diff --git a/test/sub_current/test_transaction.py b/test/sub_current/test_transaction.py index 152071d9e..a951db679 100755 --- a/test/sub_current/test_transaction.py +++ b/test/sub_current/test_transaction.py @@ -5,12 +5,14 @@ ModbusIOException, ) from pymodbus.factory import ServerDecoder +from pymodbus.framer import ( + FramerAscii, + FramerRTU, + FramerSocket, + FramerTLS, +) from pymodbus.pdu import ModbusRequest from pymodbus.transaction import ( - ModbusAsciiFramer, - ModbusRtuFramer, - ModbusSocketFramer, - ModbusTlsFramer, ModbusTransactionManager, SyncModbusTransactionManager, ) @@ -38,10 +40,10 @@ def setup_method(self): """Set up the test environment.""" self.client = None self.decoder = ServerDecoder() - self._tcp = ModbusSocketFramer(decoder=self.decoder, client=None) - self._tls = ModbusTlsFramer(decoder=self.decoder, client=None) - self._rtu = ModbusRtuFramer(decoder=self.decoder, client=None) - self._ascii = ModbusAsciiFramer(decoder=self.decoder, client=None) + self._tcp = FramerSocket(self.decoder, [0]) + self._tls = FramerTLS(self.decoder, [0]) + self._rtu = FramerRTU(self.decoder, [0]) + self._ascii = FramerAscii(self.decoder, [0]) self._manager = SyncModbusTransactionManager(self.client, 3) # ----------------------------------------------------------------------- # diff --git a/test/sub_server/test_server_asyncio.py b/test/sub_server/test_server_asyncio.py index e3de50956..fb9d8dbfc 100755 --- a/test/sub_server/test_server_asyncio.py +++ b/test/sub_server/test_server_asyncio.py @@ -215,7 +215,7 @@ async def test_async_tcp_server_receive_data(self): BasicClient.data = b"\x01\x00\x00\x00\x00\x06\x01\x03\x00\x00\x00\x19" await self.start_server() with mock.patch( - "pymodbus.transaction.ModbusSocketFramer.processIncomingPacket", + "pymodbus.framer.FramerSocket.processIncomingPacket", new_callable=mock.Mock, ) as process: await self.connect_server() @@ -345,7 +345,7 @@ async def test_async_udp_server_exception(self): BasicClient.done = asyncio.Future() await self.start_server(do_udp=True) with mock.patch( - "pymodbus.transaction.ModbusSocketFramer.processIncomingPacket", + "pymodbus.framer.FramerSocket.processIncomingPacket", new_callable=lambda: mock.Mock(side_effect=Exception), ): # get the random server port pylint: disable=protected-access @@ -361,7 +361,7 @@ async def test_async_tcp_server_exception(self): BasicClient.data = b"\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF" await self.start_server() with mock.patch( - "pymodbus.transaction.ModbusSocketFramer.processIncomingPacket", + "pymodbus.framer.FramerSocket.processIncomingPacket", new_callable=lambda: mock.Mock(side_effect=Exception), ): await self.connect_server() From 774c3a73d24ed920f307919cd2d7a75a8b0d5c7f Mon Sep 17 00:00:00 2001 From: Yash Jani Date: Tue, 8 Oct 2024 17:03:52 +0530 Subject: [PATCH 38/41] Readme file renamed (#2357) Co-authored-by: jan iversen --- doc/source/{readme.rst => README.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/source/{readme.rst => README.rst} (100%) diff --git a/doc/source/readme.rst b/doc/source/README.rst similarity index 100% rename from doc/source/readme.rst rename to doc/source/README.rst From a4f5193118e993556ff39ec8b7424a0908cba3be Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 8 Oct 2024 17:56:12 +0200 Subject: [PATCH 39/41] Framer, final touches. (#2360) --- examples/message_parser.py | 8 +- pymodbus/client/base.py | 2 +- pymodbus/client/modbusclientprotocol.py | 4 +- pymodbus/framer/base.py | 12 +- pymodbus/framer/rtu.py | 2 +- pymodbus/server/async_io.py | 14 +-- pymodbus/transaction.py | 9 +- test/framers/test_multidrop.py | 42 +++---- .../sub_client/test_client_faulty_response.py | 12 +- test/sub_client/test_client_sync.py | 4 +- test/sub_current/test_transaction.py | 117 ++++++------------ 11 files changed, 88 insertions(+), 138 deletions(-) diff --git a/examples/message_parser.py b/examples/message_parser.py index 3074a15d1..659d2747a 100755 --- a/examples/message_parser.py +++ b/examples/message_parser.py @@ -73,14 +73,14 @@ def decode(self, message): print(f"Decoding Message {value}") print("=" * 80) decoders = [ - self.framer(ServerDecoder(), [0]), - self.framer(ClientDecoder(), [0]), + self.framer(ServerDecoder(), []), + self.framer(ClientDecoder(), []), ] for decoder in decoders: - print(f"{decoder.message_handler.decoder.__class__.__name__}") + print(f"{decoder.decoder.__class__.__name__}") print("-" * 80) try: - decoder.processIncomingPacket(message, self.report, 0) + decoder.processIncomingPacket(message, self.report) except Exception: # pylint: disable=broad-except self.check_errors(decoder, message) diff --git a/pymodbus/client/base.py b/pymodbus/client/base.py index 83be824d6..34ebd1953 100644 --- a/pymodbus/client/base.py +++ b/pymodbus/client/base.py @@ -210,7 +210,7 @@ def register(self, custom_response_class: ModbusResponse) -> None: Use register() to add non-standard responses (like e.g. a login prompt) and have them interpreted automatically. """ - self.framer.message_handler.decoder.register(custom_response_class) + self.framer.decoder.register(custom_response_class) def idle_time(self) -> float: """Time before initiating next transaction (call **sync**). diff --git a/pymodbus/client/modbusclientprotocol.py b/pymodbus/client/modbusclientprotocol.py index 12a9c2535..a06025f4d 100644 --- a/pymodbus/client/modbusclientprotocol.py +++ b/pymodbus/client/modbusclientprotocol.py @@ -35,7 +35,7 @@ def __init__( self.on_connect_callback = on_connect_callback # Common variables. - self.framer: FramerBase = (FRAMER_NAME_TO_CLASS[framer])(ClientDecoder(), [0]) + self.framer: FramerBase = (FRAMER_NAME_TO_CLASS[framer])(ClientDecoder(), []) self.transaction = ModbusTransactionManager() def _handle_response(self, reply): @@ -68,7 +68,7 @@ def callback_data(self, data: bytes, addr: tuple | None = None) -> int: returns number of bytes consumed """ - self.framer.processIncomingPacket(data, self._handle_response, 0) + self.framer.processIncomingPacket(data, self._handle_response) return len(data) def __str__(self): diff --git a/pymodbus/framer/base.py b/pymodbus/framer/base.py index 490559853..99929f29b 100644 --- a/pymodbus/framer/base.py +++ b/pymodbus/framer/base.py @@ -36,11 +36,12 @@ def __init__( ) -> None: """Initialize a ADU (framer) instance.""" self.decoder = decoder + if 0 in dev_ids: + dev_ids = [] self.dev_ids = dev_ids self.incoming_dev_id = 0 self.incoming_tid = 0 self.databuffer = b"" - self.message_handler = self def decode(self, data: bytes) -> tuple[int, bytes]: """Decode ADU. @@ -85,7 +86,7 @@ def buildPacket(self, message: ModbusRequest | ModbusResponse) -> bytes: packet = self.encode(data, message.slave_id, message.transaction_id) return packet - def processIncomingPacket(self, data: bytes, callback, slave, tid=None): + def processIncomingPacket(self, data: bytes, callback, tid=None): """Process new packet pattern. This takes in a new request packet, adds it to the current @@ -99,11 +100,6 @@ def processIncomingPacket(self, data: bytes, callback, slave, tid=None): """ Log.debug("Processing: {}", data, ":hex") self.databuffer += data - if self.databuffer == b'': - return - if not isinstance(slave, (list, tuple)): - slave = [slave] - self.dev_ids = slave while True: if self.databuffer == b'': return @@ -112,7 +108,7 @@ def processIncomingPacket(self, data: bytes, callback, slave, tid=None): self.databuffer = self.databuffer[used_len:] if not data: return - if slave and 0 not in slave and self.incoming_dev_id not in slave: + if self.dev_ids and self.incoming_dev_id not in self.dev_ids: Log.debug("Not a valid slave id - {}, ignoring!!", self.incoming_dev_id) self.databuffer = b'' continue diff --git a/pymodbus/framer/rtu.py b/pymodbus/framer/rtu.py index 76b9da54c..8ea0a9786 100644 --- a/pymodbus/framer/rtu.py +++ b/pymodbus/framer/rtu.py @@ -101,7 +101,7 @@ def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: return used_len, self.EMPTY self.incoming_dev_id = int(data[used_len]) func_code = int(data[used_len + 1]) - if (self.dev_ids[0] and self.incoming_dev_id not in self.dev_ids) or func_code & 0x7F not in self.decoder.lookup: + if (self.dev_ids and self.incoming_dev_id not in self.dev_ids) or func_code & 0x7F not in self.decoder.lookup: continue if data_len - used_len < self.MIN_SIZE: Log.debug("Garble in front {}, then short frame: {} wait for more data", used_len, data, ":hex") diff --git a/pymodbus/server/async_io.py b/pymodbus/server/async_io.py index 378b8cd49..7ad9d19d4 100644 --- a/pymodbus/server/async_io.py +++ b/pymodbus/server/async_io.py @@ -64,11 +64,17 @@ def callback_new_connection(self) -> ModbusProtocol: def callback_connected(self) -> None: """Call when connection is succcesfull.""" + slaves = self.server.context.slaves() + if self.server.broadcast_enable: + if 0 not in slaves: + slaves.append(0) + if 0 in slaves: + slaves = [] try: self.running = True self.framer = self.server.framer( self.server.decoder, - [0], + slaves, ) # schedule the connection handler on the event loop @@ -106,7 +112,6 @@ def callback_disconnected(self, call_exc: Exception | None) -> None: async def inner_handle(self): """Handle handler.""" - slaves = self.server.context.slaves() # this is an asyncio.Queue await, it will never fail data = await self._recv_() if isinstance(data, tuple): @@ -117,15 +122,10 @@ async def inner_handle(self): # if broadcast is enabled make sure to # process requests to address 0 - if self.server.broadcast_enable: - if 0 not in slaves: - slaves.append(0) - Log.debug("Handling data: {}", data, ":hex") self.framer.processIncomingPacket( data=data, callback=lambda x: self.execute(x, *addr), - slave=slaves, ) async def handle(self) -> None: diff --git a/pymodbus/transaction.py b/pymodbus/transaction.py index f4d540e7a..327f84657 100644 --- a/pymodbus/transaction.py +++ b/pymodbus/transaction.py @@ -187,10 +187,10 @@ def execute(self, request: ModbusRequest): # noqa: C901 request.transaction_id = self.getNextTID() Log.debug("Running transaction {}", request.transaction_id) if _buffer := hexlify_packets( - self.client.framer.message_handler.databuffer + self.client.framer.databuffer ): Log.debug("Clearing current Frame: - {}", _buffer) - self.client.framer.message_handler.databuffer = b'' + self.client.framer.databuffer = b'' broadcast = not request.slave_id expected_response_length = None if not isinstance(self.client.framer, FramerSocket): @@ -235,7 +235,6 @@ def execute(self, request: ModbusRequest): # noqa: C901 self.client.framer.processIncomingPacket( response, self.addTransaction, - request.slave_id, tid=request.transaction_id, ) if not (response := self.getTransaction(request.transaction_id)): @@ -259,7 +258,7 @@ def execute(self, request: ModbusRequest): # noqa: C901 self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE return response except ModbusIOException as exc: - # Handle decode errors in processIncomingPacket method + # Handle decode errors method Log.error("Modbus IO exception {}", exc) self.client.state = ModbusTransactionState.TRANSACTION_COMPLETE self.client.close() @@ -425,5 +424,5 @@ def _get_expected_response_length(self, data) -> int: :return: Total frame size """ func_code = int(data[1]) - pdu_class = self.client.framer.message_handler.decoder.lookupPduClass(func_code) + pdu_class = self.client.framer.decoder.lookupPduClass(func_code) return pdu_class.calculateRtuFrameSize(data) diff --git a/test/framers/test_multidrop.py b/test/framers/test_multidrop.py index ca4476306..aeb427881 100644 --- a/test/framers/test_multidrop.py +++ b/test/framers/test_multidrop.py @@ -10,14 +10,12 @@ class NotImplementedTestMultidrop: """Test that server works on a multidrop line.""" - slaves = [2] - good_frame = b"\x02\x03\x00\x01\x00}\xd4\x18" @pytest.fixture(name="framer") def fixture_framer(self): """Prepare framer.""" - return FramerRTU(ServerDecoder(), [0]) + return FramerRTU(ServerDecoder(), [2]) @pytest.fixture(name="callback") def fixture_callback(self): @@ -27,25 +25,25 @@ def fixture_callback(self): def test_ok_frame(self, framer, callback): """Test ok frame.""" serial_event = self.good_frame - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() def test_ok_2frame(self, framer, callback): """Test ok frame.""" serial_event = self.good_frame + self.good_frame - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) assert callback.call_count == 2 def test_bad_crc(self, framer, callback): """Test bad crc.""" serial_event = b"\x02\x03\x00\x01\x00}\xd4\x19" # Manually mangled crc - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_not_called() def test_wrong_id(self, framer, callback): """Test frame wrong id.""" serial_event = b"\x01\x03\x00\x01\x00}\xd4+" # Frame with good CRC but other id - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_not_called() def test_big_split_response_frame_from_other_id(self, framer, callback): @@ -64,28 +62,28 @@ def test_big_split_response_frame_from_other_id(self, framer, callback): b"\x00\x00\x00\x00\x00\x00\x00N,", ] for serial_event in serial_events: - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_not_called() def test_split_frame(self, framer, callback): """Test split frame.""" serial_events = [self.good_frame[:5], self.good_frame[5:]] for serial_event in serial_events: - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() def test_complete_frame_trailing_data_without_id(self, framer, callback): """Test trailing data.""" garbage = b"\x05\x04\x03" # without id serial_event = garbage + self.good_frame - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() def test_complete_frame_trailing_data_with_id(self, framer, callback): """Test trailing data.""" garbage = b"\x05\x04\x03\x02\x01\x00" # with id serial_event = garbage + self.good_frame - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() def test_split_frame_trailing_data_with_id(self, framer, callback): @@ -93,7 +91,7 @@ def test_split_frame_trailing_data_with_id(self, framer, callback): garbage = b"\x05\x04\x03\x02\x01\x00" serial_events = [garbage + self.good_frame[:5], self.good_frame[5:]] for serial_event in serial_events: - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() def test_coincidental_1(self, framer, callback): @@ -101,7 +99,7 @@ def test_coincidental_1(self, framer, callback): garbage = b"\x02\x90\x07" serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]] for serial_event in serial_events: - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() def test_coincidental_2(self, framer, callback): @@ -109,7 +107,7 @@ def test_coincidental_2(self, framer, callback): garbage = b"\x02\x10\x07" serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]] for serial_event in serial_events: - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() def test_coincidental_3(self, framer, callback): @@ -117,14 +115,14 @@ def test_coincidental_3(self, framer, callback): garbage = b"\x02\x10\x07\x10" serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]] for serial_event in serial_events: - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() def test_wrapped_frame(self, framer, callback): """Test wrapped frame.""" garbage = b"\x05\x04\x03\x02\x01\x00" serial_event = garbage + self.good_frame + garbage - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) # We probably should not respond in this case; in this case we've likely become desynchronized # i.e. this probably represents a case where a command came for us, but we didn't get @@ -136,7 +134,7 @@ def test_frame_with_trailing_data(self, framer, callback): """Test trailing data.""" garbage = b"\x05\x04\x03\x02\x01\x00" serial_event = self.good_frame + garbage - framer.processIncomingPacket(serial_event, callback, self.slaves) + framer.processIncomingPacket(serial_event, callback) # We should not respond in this case for identical reasons as test_wrapped_frame callback.assert_called_once() @@ -152,23 +150,23 @@ def test_callback(data): result = data.function_code.to_bytes(1,'big')+data.encode() framer_ok = b"\x02\x03\x00\x01\x00\x7d\xd4\x18" - framer.processIncomingPacket(framer_ok, test_callback, self.slaves) + framer.processIncomingPacket(framer_ok, test_callback) assert framer_ok[1:-2] == result count = 0 framer_2ok = framer_ok + framer_ok - framer.processIncomingPacket(framer_2ok, test_callback, self.slaves) + framer.processIncomingPacket(framer_2ok, test_callback) assert count == 2 assert not framer._buffer # pylint: disable=protected-access framer._buffer = framer_ok[:2] # pylint: disable=protected-access - framer.processIncomingPacket(b'', test_callback, self.slaves) + framer.processIncomingPacket(b'', test_callback) assert framer_ok[:2] == framer._buffer # pylint: disable=protected-access framer._buffer = framer_ok[:3] # pylint: disable=protected-access - framer.processIncomingPacket(b'', test_callback, self.slaves) + framer.processIncomingPacket(b'', test_callback) assert framer_ok[:3] == framer._buffer # pylint: disable=protected-access framer_ok = b"\xF0\x03\x00\x01\x00}\xd4\x18" - framer.processIncomingPacket(framer_ok, test_callback, self.slaves) + framer.processIncomingPacket(framer_ok, test_callback) assert framer._buffer == framer_ok[-3:] # pylint: disable=protected-access diff --git a/test/sub_client/test_client_faulty_response.py b/test/sub_client/test_client_faulty_response.py index a31235953..a14e1890f 100644 --- a/test/sub_client/test_client_faulty_response.py +++ b/test/sub_client/test_client_faulty_response.py @@ -11,14 +11,12 @@ class TestFaultyResponses: """Test that server works on a multidrop line.""" - slaves = [0] - good_frame = b"\x00\x01\x00\x00\x00\x05\x00\x03\x02\x00\x01" @pytest.fixture(name="framer") def fixture_framer(self): """Prepare framer.""" - return FramerSocket(ClientDecoder(), [0]) + return FramerSocket(ClientDecoder(), []) @pytest.fixture(name="callback") def fixture_callback(self): @@ -27,21 +25,21 @@ def fixture_callback(self): def test_ok_frame(self, framer, callback): """Test ok frame.""" - framer.processIncomingPacket(self.good_frame, callback, self.slaves) + framer.processIncomingPacket(self.good_frame, callback) callback.assert_called_once() def test_1917_frame(self, callback): """Test invalid frame in issue 1917.""" recv = b"\x01\x86\x02\x00\x01" framer = FramerRTU(ClientDecoder(), [0]) - framer.processIncomingPacket(recv, callback, self.slaves) + framer.processIncomingPacket(recv, callback) callback.assert_not_called() def test_faulty_frame1(self, framer, callback): """Test ok frame.""" faulty_frame = b"\x00\x04\x00\x00\x00\x05\x00\x03\x0a\x00\x04" with pytest.raises(ModbusIOException): - framer.processIncomingPacket(faulty_frame, callback, self.slaves) + framer.processIncomingPacket(faulty_frame, callback) callback.assert_not_called() - framer.processIncomingPacket(self.good_frame, callback, self.slaves) + framer.processIncomingPacket(self.good_frame, callback) callback.assert_called_once() diff --git a/test/sub_client/test_client_sync.py b/test/sub_client/test_client_sync.py index e2dd62d8a..68d0080aa 100755 --- a/test/sub_client/test_client_sync.py +++ b/test/sub_client/test_client_sync.py @@ -206,7 +206,7 @@ class CustomRequest: # pylint: disable=too-few-public-methods client = ModbusTcpClient("127.0.0.1") client.framer = mock.Mock() client.register(CustomRequest) - client.framer.message_handler.decoder.register.assert_called_once_with(CustomRequest) + client.framer.decoder.register.assert_called_once_with(CustomRequest) # -----------------------------------------------------------------------# # Test TLS Client @@ -296,7 +296,7 @@ class CustomRequest: # pylint: disable=too-few-public-methods client = ModbusTlsClient("127.0.0.1") client.framer = mock.Mock() client.register(CustomRequest) - client.framer.message_handler.decoder.register.assert_called_once_with(CustomRequest) + client.framer.decoder.register.assert_called_once_with(CustomRequest) # -----------------------------------------------------------------------# # Test Serial Client diff --git a/test/sub_current/test_transaction.py b/test/sub_current/test_transaction.py index a951db679..bef0eda22 100755 --- a/test/sub_current/test_transaction.py +++ b/test/sub_current/test_transaction.py @@ -40,10 +40,10 @@ def setup_method(self): """Set up the test environment.""" self.client = None self.decoder = ServerDecoder() - self._tcp = FramerSocket(self.decoder, [0]) - self._tls = FramerTLS(self.decoder, [0]) - self._rtu = FramerRTU(self.decoder, [0]) - self._ascii = FramerAscii(self.decoder, [0]) + self._tcp = FramerSocket(self.decoder, []) + self._tls = FramerTLS(self.decoder, []) + self._rtu = FramerRTU(self.decoder, []) + self._ascii = FramerAscii(self.decoder, []) self._manager = SyncModbusTransactionManager(self.client, 3) # ----------------------------------------------------------------------- # @@ -198,7 +198,7 @@ def callback(data): result = data msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg, callback, [1]) + self._tcp.processIncomingPacket(msg, callback) self._tcp._buffer = msg # pylint: disable=protected-access callback(b'') @@ -213,7 +213,7 @@ def callback(data): result = data msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg, callback, [0, 1]) + self._tcp.processIncomingPacket(msg, callback) assert result.function_code.to_bytes(1,'big') + result.encode() == msg[7:] def test_tcp_framer_transaction_half(self): @@ -228,9 +228,9 @@ def callback(data): msg1 = b"\x00\x01\x12\x34\x00" msg2 = b"\x06\xff\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg1, callback, [0, 1]) + self._tcp.processIncomingPacket(msg1, callback) assert not result - self._tcp.processIncomingPacket(msg2, callback, [0, 1]) + self._tcp.processIncomingPacket(msg2, callback) assert result assert result.function_code.to_bytes(1,'big') + result.encode() == msg2[2:] @@ -246,9 +246,9 @@ def callback(data): msg1 = b"\x00\x01\x12\x34\x00\x06\xff" msg2 = b"\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg1, callback, [0, 1]) + self._tcp.processIncomingPacket(msg1, callback) assert not result - self._tcp.processIncomingPacket(msg2, callback, [0, 1]) + self._tcp.processIncomingPacket(msg2, callback) assert result assert result.function_code.to_bytes(1,'big') + result.encode() == msg2 @@ -264,9 +264,9 @@ def callback(data): msg1 = b"\x00\x01\x12\x34\x00\x06\xff\x02\x01\x02\x00" msg2 = b"\x08" - self._tcp.processIncomingPacket(msg1, callback, [0, 1]) + self._tcp.processIncomingPacket(msg1, callback) assert not result - self._tcp.processIncomingPacket(msg2, callback, [0, 1]) + self._tcp.processIncomingPacket(msg2, callback) assert result assert result.function_code.to_bytes(1,'big') + result.encode() == msg1[7:] + msg2 @@ -283,9 +283,9 @@ def callback(data): # msg1 = b"\x99\x99\x99\x99\x00\x01\x00\x17" msg1 = b'' msg2 = b"\x00\x01\x12\x34\x00\x06\xff\x02\x01\x02\x00\x08" - self._tcp.processIncomingPacket(msg1, callback, [0, 1]) + self._tcp.processIncomingPacket(msg1, callback) assert not result - self._tcp.processIncomingPacket(msg2, callback, [0, 1]) + self._tcp.processIncomingPacket(msg2, callback) assert result assert result.function_code.to_bytes(1,'big') + result.encode() == msg2[7:] @@ -303,7 +303,7 @@ def callback(data): expected.transaction_id = 0x0001 expected.slave_id = 0xFF msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg, callback, [0, 1]) + self._tcp.processIncomingPacket(msg, callback) @mock.patch.object(ModbusRequest, "encode") def test_tcp_framer_packet(self, mock_encode): @@ -331,9 +331,9 @@ def callback(data): result = data msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg[0:4], callback, [0, 1]) + self._tcp.processIncomingPacket(msg[0:4], callback) assert not result - self._tcp.processIncomingPacket(msg[4:], callback, [0, 1]) + self._tcp.processIncomingPacket(msg[4:], callback) assert result def test_framer_tls_framer_transaction_full(self): @@ -347,7 +347,7 @@ def callback(data): result = data msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg, callback, [0, 1]) + self._tcp.processIncomingPacket(msg, callback) assert result def test_framer_tls_framer_transaction_half(self): @@ -361,9 +361,9 @@ def callback(data): result = data msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg[0:8], callback, [0, 1]) + self._tcp.processIncomingPacket(msg[0:8], callback) assert not result - self._tcp.processIncomingPacket(msg[8:], callback, [0, 1]) + self._tcp.processIncomingPacket(msg[8:], callback) assert result def test_framer_tls_framer_transaction_short(self): @@ -377,16 +377,14 @@ def callback(data): result = data msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg[0:2], callback, [0, 1]) + self._tcp.processIncomingPacket(msg[0:2], callback) assert not result - self._tcp.processIncomingPacket(msg[2:], callback, [0, 1]) + self._tcp.processIncomingPacket(msg[2:], callback) assert result def test_framer_tls_incoming_packet(self): """Framer tls incoming packet.""" msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - - slave = 0x01 msg_result = None def mock_callback(result): @@ -395,46 +393,9 @@ def mock_callback(result): msg_result = result.encode() - self._tls.processIncomingPacket(msg, mock_callback, slave) + self._tls.processIncomingPacket(msg, mock_callback) # assert msg == msg_result - # self._tls.isFrameReady = mock.MagicMock(return_value=True) - # x = mock.MagicMock(return_value=False) - # self._tls._validate_slave_id = x - # self._tls.processIncomingPacket(msg, mock_callback, slave) - # assert not self._tls._buffer - # self._tls.advanceFrame() - # x = mock.MagicMock(return_value=True) - # self._tls._validate_slave_id = x - # self._tls.processIncomingPacket(msg, mock_callback, slave) - # assert msg[1:] == msg_result - # self._tls.advanceFrame() - - def test_framer_tls_process(self): - """Framer tls process.""" - # class MockResult: - # """Mock result.""" - - # def __init__(self, code): - # """Init.""" - # self.function_code = code - - # def mock_callback(_arg): - # """Mock callback.""" - - # self._tls.decoder.decode = mock.MagicMock(return_value=None) - # with pytest.raises(ModbusIOException): - # self._tls._process(mock_callback) - - # result = MockResult(0x01) - # self._tls.decoder.decode = mock.MagicMock(return_value=result) - # with pytest.raises(InvalidMessageReceivedException): - # self._tls._process( - # mock_callback, error=True - # ) - # self._tls._process(mock_callback) - # assert not self._tls._buffer - def test_framer_tls_framer_populate(self): """Test a tls frame packet build.""" count = 0 @@ -446,7 +407,7 @@ def callback(data): result = data msg = b"\x00\x01\x12\x34\x00\x06\xff\x02\x12\x34\x01\x02" - self._tcp.processIncomingPacket(msg, callback, [0, 1]) + self._tcp.processIncomingPacket(msg, callback) assert result @mock.patch.object(ModbusRequest, "encode") @@ -473,9 +434,9 @@ def callback(data): result = data msg_parts = [b"\x00\x01\x00", b"\x00\x00\x01\xfc\x1b"] - self._rtu.processIncomingPacket(msg_parts[0], callback, [0, 1]) + self._rtu.processIncomingPacket(msg_parts[0], callback) assert not result - self._rtu.processIncomingPacket(msg_parts[1], callback, [0, 1]) + self._rtu.processIncomingPacket(msg_parts[1], callback) assert result def test_rtu_framer_transaction_full(self): @@ -489,7 +450,7 @@ def callback(data): result = data msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" - self._rtu.processIncomingPacket(msg, callback, [0, 1]) + self._rtu.processIncomingPacket(msg, callback) assert result def test_rtu_framer_transaction_half(self): @@ -503,9 +464,9 @@ def callback(data): result = data msg_parts = [b"\x00\x01\x00", b"\x00\x00\x01\xfc\x1b"] - self._rtu.processIncomingPacket(msg_parts[0], callback, [0, 1]) + self._rtu.processIncomingPacket(msg_parts[0], callback) assert not result - self._rtu.processIncomingPacket(msg_parts[1], callback, [0, 1]) + self._rtu.processIncomingPacket(msg_parts[1], callback) assert result def test_rtu_framer_populate(self): @@ -519,7 +480,7 @@ def callback(data): result = data msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" - self._rtu.processIncomingPacket(msg, callback, [0, 1]) + self._rtu.processIncomingPacket(msg, callback) assert int(msg[0]) == self._rtu.incoming_dev_id @mock.patch.object(ModbusRequest, "encode") @@ -544,7 +505,7 @@ def callback(data): result = data msg = b"\x00\x90\x02\x9c\x01" - self._rtu.processIncomingPacket(msg, callback, [0, 1]) + self._rtu.processIncomingPacket(msg, callback) assert result def test_process(self): @@ -558,7 +519,7 @@ def callback(data): result = data msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" - self._rtu.processIncomingPacket(msg, callback, [0, 1]) + self._rtu.processIncomingPacket(msg, callback) assert result def test_rtu_process_incoming_packets(self): @@ -572,9 +533,7 @@ def callback(data): result = data msg = b"\x00\x01\x00\x00\x00\x01\xfc\x1b" - slave = 0x00 - - self._rtu.processIncomingPacket(msg, callback, slave) + self._rtu.processIncomingPacket(msg, callback) assert result # ----------------------------------------------------------------------- # @@ -591,7 +550,7 @@ def callback(data): result = data msg = b":F7031389000A60\r\n" - self._ascii.processIncomingPacket(msg, callback, [0,1]) + self._ascii.processIncomingPacket(msg, callback) assert result def test_ascii_framer_transaction_full(self): @@ -605,7 +564,7 @@ def callback(data): result = data msg = b"sss:F7031389000A60\r\n" - self._ascii.processIncomingPacket(msg, callback, [0,1]) + self._ascii.processIncomingPacket(msg, callback) assert result def test_ascii_framer_transaction_half(self): @@ -619,9 +578,9 @@ def callback(data): result = data msg_parts = (b"sss:F7031389", b"000A60\r\n") - self._ascii.processIncomingPacket(msg_parts[0], callback, [0,1]) + self._ascii.processIncomingPacket(msg_parts[0], callback) assert not result - self._ascii.processIncomingPacket(msg_parts[1], callback, [0,1]) + self._ascii.processIncomingPacket(msg_parts[1], callback) assert result def test_ascii_process_incoming_packets(self): @@ -635,5 +594,5 @@ def callback(data): result = data msg = b":F7031389000A60\r\n" - self._ascii.processIncomingPacket(msg, callback, [0,1]) + self._ascii.processIncomingPacket(msg, callback) assert result From c8cacc52ac5969a0d21909589631d8d7cfc98050 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 8 Oct 2024 19:46:49 +0200 Subject: [PATCH 40/41] 100% test coverage of framers (#2359) --- pymodbus/framer/base.py | 3 +-- pymodbus/framer/rtu.py | 12 ++++++------ test/framers/conftest.py | 2 +- test/framers/test_framer.py | 29 ++++++++++++++++++++++++----- test/framers/test_multidrop.py | 23 +++++++++++++++-------- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/pymodbus/framer/base.py b/pymodbus/framer/base.py index 99929f29b..bad9c71b4 100644 --- a/pymodbus/framer/base.py +++ b/pymodbus/framer/base.py @@ -104,8 +104,7 @@ def processIncomingPacket(self, data: bytes, callback, tid=None): if self.databuffer == b'': return used_len, data = self.decode(self.databuffer) - if used_len: - self.databuffer = self.databuffer[used_len:] + self.databuffer = self.databuffer[used_len:] if not data: return if self.dev_ids and self.incoming_dev_id not in self.dev_ids: diff --git a/pymodbus/framer/rtu.py b/pymodbus/framer/rtu.py index 8ea0a9786..05417013a 100644 --- a/pymodbus/framer/rtu.py +++ b/pymodbus/framer/rtu.py @@ -95,7 +95,7 @@ def generate_crc16_table(cls) -> list[int]: def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: """Decode ADU.""" - for used_len in range(data_len): + for used_len in range(data_len): # pragma: no cover if data_len - used_len < self.MIN_SIZE: Log.debug("Short frame: {} wait for more data", data, ":hex") return used_len, self.EMPTY @@ -103,19 +103,19 @@ def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: func_code = int(data[used_len + 1]) if (self.dev_ids and self.incoming_dev_id not in self.dev_ids) or func_code & 0x7F not in self.decoder.lookup: continue - if data_len - used_len < self.MIN_SIZE: + if data_len - used_len < self.MIN_SIZE: # pragma: no cover Log.debug("Garble in front {}, then short frame: {} wait for more data", used_len, data, ":hex") return used_len, self.EMPTY pdu_class = self.decoder.lookupPduClass(func_code) try: size = pdu_class.calculateRtuFrameSize(data[used_len:]) - except IndexError: + except IndexError: # pragma: no cover size = data_len +1 if data_len < used_len +size: Log.debug("Frame - not ready") - if used_len: + if used_len: # pragma: no cover continue - return used_len, self.EMPTY + return used_len, self.EMPTY # pragma: no cover start_crc = used_len + size -2 crc = data[start_crc : start_crc + 2] crc_val = (int(crc[0]) << 8) + int(crc[1]) @@ -123,7 +123,7 @@ def specific_decode(self, data: bytes, data_len: int) -> tuple[int, bytes]: Log.debug("Frame check failed, ignoring!!") continue return start_crc + 2, data[used_len + 1 : start_crc] - return used_len, self.EMPTY + return used_len, self.EMPTY # pragma: no cover def encode(self, pdu: bytes, device_id: int, _tid: int) -> bytes: diff --git a/test/framers/conftest.py b/test/framers/conftest.py index fa157404f..18720c6b7 100644 --- a/test/framers/conftest.py +++ b/test/framers/conftest.py @@ -10,7 +10,7 @@ @pytest.fixture(name="entry") def prepare_entry(): """Return framer_type.""" - return FramerType.ASCII + return FramerType.RTU @pytest.fixture(name="is_server") def prepare_is_server(): diff --git a/test/framers/test_framer.py b/test/framers/test_framer.py index 7f11faf12..0a331caf1 100644 --- a/test/framers/test_framer.py +++ b/test/framers/test_framer.py @@ -4,16 +4,34 @@ import pytest from pymodbus.factory import ClientDecoder -from pymodbus.framer import FramerType -from pymodbus.framer.ascii import FramerAscii -from pymodbus.framer.rtu import FramerRTU -from pymodbus.framer.socket import FramerSocket -from pymodbus.framer.tls import FramerTLS +from pymodbus.framer import ( + FramerAscii, + FramerBase, + FramerRTU, + FramerSocket, + FramerTLS, + FramerType, +) + +from .generator import set_calls class TestFramer: """Test module.""" + def test_setup(self, entry, is_server, dev_ids): + """Test conftest.""" + assert entry == FramerType.RTU + assert not is_server + assert dev_ids == [0, 17] + set_calls() + + def test_base(self): + """Test FramerBase.""" + framer = FramerBase(ClientDecoder(), []) + framer.decode(b'') + framer.encode(b'', 0, 0) + @pytest.mark.parametrize(("entry"), list(FramerType)) async def test_framer_init(self, test_framer): """Test framer type.""" @@ -291,6 +309,7 @@ async def test_decode_type(self, entry, test_framer, data, dev_id, tr_id, expect (12, b"\x03\x00\x7c\x00\x02"), (12, b"\x03\x00\x7c\x00\x02"), ]), + (FramerType.SOCKET, b'\x0c\x05\x00\x00\x00\x02\xff\x83\x02', [(9, b'\x83\x02')],), # Exception (FramerType.RTU, b'\x00\x83\x02\x91\x21', [ # bad crc (2, b''), ]), diff --git a/test/framers/test_multidrop.py b/test/framers/test_multidrop.py index aeb427881..1baa87604 100644 --- a/test/framers/test_multidrop.py +++ b/test/framers/test_multidrop.py @@ -7,7 +7,7 @@ from pymodbus.server.async_io import ServerDecoder -class NotImplementedTestMultidrop: +class TestMultidrop: """Test that server works on a multidrop line.""" good_frame = b"\x02\x03\x00\x01\x00}\xd4\x18" @@ -28,7 +28,8 @@ def test_ok_frame(self, framer, callback): framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() - def test_ok_2frame(self, framer, callback): + @pytest.mark.skip + def test_ok_2frame(self, framer, callback): # pragma: no cover """Test ok frame.""" serial_event = self.good_frame + self.good_frame framer.processIncomingPacket(serial_event, callback) @@ -65,7 +66,8 @@ def test_big_split_response_frame_from_other_id(self, framer, callback): framer.processIncomingPacket(serial_event, callback) callback.assert_not_called() - def test_split_frame(self, framer, callback): + @pytest.mark.skip + def test_split_frame(self, framer, callback): # pragma: no cover """Test split frame.""" serial_events = [self.good_frame[:5], self.good_frame[5:]] for serial_event in serial_events: @@ -86,7 +88,8 @@ def test_complete_frame_trailing_data_with_id(self, framer, callback): framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() - def test_split_frame_trailing_data_with_id(self, framer, callback): + @pytest.mark.skip + def test_split_frame_trailing_data_with_id(self, framer, callback): # pragma: no cover """Test split frame.""" garbage = b"\x05\x04\x03\x02\x01\x00" serial_events = [garbage + self.good_frame[:5], self.good_frame[5:]] @@ -94,7 +97,8 @@ def test_split_frame_trailing_data_with_id(self, framer, callback): framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() - def test_coincidental_1(self, framer, callback): + @pytest.mark.skip + def test_coincidental_1(self, framer, callback): # pragma: no cover """Test conincidental.""" garbage = b"\x02\x90\x07" serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]] @@ -102,7 +106,8 @@ def test_coincidental_1(self, framer, callback): framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() - def test_coincidental_2(self, framer, callback): + @pytest.mark.skip + def test_coincidental_2(self, framer, callback): # pragma: no cover """Test conincidental.""" garbage = b"\x02\x10\x07" serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]] @@ -110,7 +115,8 @@ def test_coincidental_2(self, framer, callback): framer.processIncomingPacket(serial_event, callback) callback.assert_called_once() - def test_coincidental_3(self, framer, callback): + @pytest.mark.skip + def test_coincidental_3(self, framer, callback): # pragma: no cover """Test conincidental.""" garbage = b"\x02\x10\x07\x10" serial_events = [garbage, self.good_frame[:5], self.good_frame[5:]] @@ -139,7 +145,8 @@ def test_frame_with_trailing_data(self, framer, callback): # We should not respond in this case for identical reasons as test_wrapped_frame callback.assert_called_once() - def test_getFrameStart(self, framer): + @pytest.mark.skip + def test_getFrameStart(self, framer): # pragma: no cover """Test getFrameStart.""" result = None count = 0 From a165c7a704a761e05f411c4e7b6b86ebb62de1d1 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 9 Oct 2024 11:29:29 +0200 Subject: [PATCH 41/41] Prepare v3.7.3. (#2361) --- AUTHORS.rst | 5 +++++ CHANGELOG.rst | 38 ++++++++++++++++++++++++++++++++ MAKE_RELEASE.rst | 6 ++--- README.rst | 2 +- doc/source/_static/examples.tgz | Bin 45644 -> 45459 bytes doc/source/_static/examples.zip | Bin 38378 -> 38372 bytes pymodbus/__init__.py | 2 +- 7 files changed, 48 insertions(+), 5 deletions(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 8903a4d19..57b03d7c4 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -16,6 +16,7 @@ Thanks to - Alexander Lanin - Alexandre CUER - Alois Hockenschlohe +- Andy Walker - Arjan - André Srinivasan - andrew-harness @@ -27,6 +28,7 @@ Thanks to - Chandler Riehm - Chris Hung - Christian Krause +- Daniel Rauber - dhoomakethu - doelki - DominicDataP @@ -64,6 +66,8 @@ Thanks to - Logan Gunthorpe - Marko Luther - Matthias Straka +- Matthias Urlichs +- Michel F - Mickaël Schoentgen - Pavel Kostromitinov - peufeu2 @@ -77,6 +81,7 @@ Thanks to - Totally a booplicate - WouterTuinstra - wriswith +- Yash Jani - Yohrog - yyokusa diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 328ef0e46..511831a37 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,44 @@ helps make pymodbus a better product. :ref:`Authors`: contains a complete list of volunteers have contributed to each major version. +Version 3.7.3 +------------- +* 100% test coverage of framers (#2359) +* Framer, final touches. (#2360) +* Readme file renamed (#2357) +* Remove old framers (#2358) +* frameProcessIncomingPacket removed (#2355) +* Cleanup framers (reduce old_framers) (#2342) +* Run CI on PR targeted at wait_next_api. +* Sync client, allow unknown recv msg size. (#2353) +* integrate old rtu framer in new framer (#2344) +* Update README.rst (#2351) +* Client.close should not allow reconnect= (#2347) +* Remove async client.idle_time(). (#2349) +* Client doc, add common methods (base). (#2348) +* Reset receive buffer with send(). (#2343) +* Remove unused protocol_id from pdu (#2340) +* CI run on demand on non-protected branches. (#2339) +* Server listener and client connections have is_server set. (#2338) +* Reopen listener in server if disconnected. (#2337) +* Regroup test. (#2335) +* Improve docs around sync clients and reconnection (#2321) +* transport 100% test coverage (again) (#2333) +* Update actions to new node.js. (#2332) +* Bump 3rd party (#2331) +* Documentation on_connect_callback (#2324) +* Fixes the unexpected implementation of the ModbusSerialClient.connected property (#2327) +* Forward error responses instead of timing out. (#2329) +* Add `stacklevel=2` to logging functions (#2330) +* Fix encoding & decoding of ReadFileRecordResponse (#2319) +* Improvements for example/contib/solar (#2318) +* Update solar.py (#2316) +* Remove double conversion in int (#2315) +* Complete pull request #2310 (#2312) +* fixed type hints for write_register and write_registers (#2309) +* Remove _header from framers. (#2305) + + Version 3.7.2 ------------- * Correct README diff --git a/MAKE_RELEASE.rst b/MAKE_RELEASE.rst index fd8660794..d3f4bc5f9 100644 --- a/MAKE_RELEASE.rst +++ b/MAKE_RELEASE.rst @@ -14,8 +14,8 @@ Prepare/make release on dev. * Control / Update API_changes.rst * Update CHANGELOG.rst * Add commits from last release, but selectively ! - git log --oneline v3.7.0..HEAD > commit.log - git log --pretty="%an" v3.7.0..HEAD | sort -uf > authors.log + git log --oneline v3.7.3..HEAD > commit.log + git log --pretty="%an" v3.7.3..HEAD | sort -uf > authors.log update AUTHORS.rst and CHANGELOG.rst cd doc; ./build_html * rm -rf build/* dist/* @@ -58,4 +58,4 @@ Architecture documentation. ------------------------------------------------------------ * install graphviz * pyreverse -k -o jpg pymodbus -l \ No newline at end of file +l diff --git a/README.rst b/README.rst index d3a96b418..5cca41287 100644 --- a/README.rst +++ b/README.rst @@ -23,7 +23,7 @@ Upgrade examples: - 3.6.1 -> 3.7.0: Smaller changes to the pymodbus calls might be needed - 2.5.4 -> 3.0.0: Major changes in the application might be needed -Current release is `3.7.2 `_. +Current release is `3.7.3 `_. Bleeding edge (not released) is `dev `_. diff --git a/doc/source/_static/examples.tgz b/doc/source/_static/examples.tgz index 15011cffdb7a5fc1f4b8c5a7f4e22fb3eb41d899..862407c1947900ce163fbd880f04a438d24c89a0 100644 GIT binary patch literal 45459 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 AbN~PV literal 45644 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 I&j6kS0MieKssI20 diff --git a/doc/source/_static/examples.zip b/doc/source/_static/examples.zip index e366078524cba38a33f13ecd8d46a279a7c45be6..402b6c3c8766aeb8f87c4165ee5ed880feaa1bef 100644 GIT binary patch delta 3772 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 Q(0!mG_8~4=51v^GxBvhE delta 3782 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 diff --git a/pymodbus/__init__.py b/pymodbus/__init__.py index eaad4542d..279621c57 100644 --- a/pymodbus/__init__.py +++ b/pymodbus/__init__.py @@ -18,5 +18,5 @@ from pymodbus.pdu import ExceptionResponse -__version__ = "3.7.3dev" +__version__ = "3.7.3" __version_full__ = f"[pymodbus, version {__version__}]"