Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions API_changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Version 3.5.0 (future)
- :code:`ModbusPlusOperation`
- :code:`Endian`
- :code:`ModbusStatus`
- Async clients now accepts `no_resend_on_retry=True`, to not resend the request when retrying.

-------------
Version 3.4.2
Expand Down
45 changes: 24 additions & 21 deletions pymodbus/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class ModbusBaseClient(ModbusClientMixin, ModbusProtocol):
:param reconnect_delay: (optional) Minimum delay in milliseconds before reconnecting.
:param reconnect_delay_max: (optional) Maximum delay in milliseconds before reconnecting.
:param on_reconnect_callback: (optional) Function that will be called just before a reconnection attempt.
:param no_resend_on_retry: (optional) Do not resend request when retrying due to missing response.
:param kwargs: (optional) Experimental parameters.

.. tip::
Expand Down Expand Up @@ -75,6 +76,7 @@ def __init__( # pylint: disable=too-many-arguments
reconnect_delay: float = 0.1,
reconnect_delay_max: float = 300,
on_reconnect_callback: Callable[[], None] | None = None,
no_resend_on_retry: bool = False,
**kwargs: Any,
) -> None:
"""Initialize a client instance."""
Expand Down Expand Up @@ -114,10 +116,8 @@ def __init__( # pylint: disable=too-many-arguments
self.reconnect_delay_max = int(reconnect_delay_max)
self.on_reconnect_callback = on_reconnect_callback
self.retry_on_empty: int = 0
# -> retry read on nothing

self.no_resend_on_retry = no_resend_on_retry
self.slaves: list[int] = []
# -> list of acceptable slaves (0 for accept all)

# Common variables.
self.framer = framer(ClientDecoder(), self)
Expand Down Expand Up @@ -189,25 +189,28 @@ async def async_execute(self, request=None):
"""Execute requests asynchronously."""
request.transaction_id = self.transaction.getNextTID()
packet = self.framer.buildPacket(request)
self.transport_send(packet)
if self.params.broadcast_enable and not request.slave_id:
resp = b"Broadcast write sent - no response expected"
else:
count = 0
while count <= self.params.retries:
try:
req = self._build_response(request.transaction_id)
resp = await asyncio.wait_for(
req, timeout=self.comm_params.timeout_connect
)
break
except asyncio.exceptions.TimeoutError:
count += 1
if count > self.params.retries:
self.close(reconnect=True)
raise ModbusIOException(
f"ERROR: No response received after {self.params.retries} retries"

count = 0
while count <= self.params.retries:
if not count or not self.no_resend_on_retry:
self.transport_send(packet)
if self.params.broadcast_enable and not request.slave_id:
resp = b"Broadcast write sent - no response expected"
break
try:
req = self._build_response(request.transaction_id)
resp = await asyncio.wait_for(
req, timeout=self.comm_params.timeout_connect
)
break
except asyncio.exceptions.TimeoutError:
count += 1
if count > self.params.retries:
self.close(reconnect=True)
raise ModbusIOException(
f"ERROR: No response received after {self.params.retries} retries"
)

return resp

def callback_data(self, data: bytes, addr: tuple = None) -> int:
Expand Down
37 changes: 35 additions & 2 deletions test/sub_client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from pymodbus.client.mixin import ModbusClientMixin
from pymodbus.datastore import ModbusSlaveContext
from pymodbus.datastore.store import ModbusSequentialDataBlock
from pymodbus.exceptions import ConnectionException
from pymodbus.exceptions import ConnectionException, ModbusIOException
from pymodbus.framer.ascii_framer import ModbusAsciiFramer
from pymodbus.framer.rtu_framer import ModbusRtuFramer
from pymodbus.framer.socket_framer import ModbusSocketFramer
Expand Down Expand Up @@ -365,9 +365,10 @@ async def test_client_protocol_handler():
class MockTransport:
"""Mock transport class which responds with an appropriate encoded packet"""

def __init__(self, base, req):
def __init__(self, base, req, retries=0):
"""Initialize MockTransport"""
self.base = base
self.retries = retries

db = ModbusSequentialDataBlock(0, [0] * 100)
self.ctx = ModbusSlaveContext(di=db, co=db, hr=db, ir=db)
Expand All @@ -382,6 +383,9 @@ async def delayed_resp(self):

def write(self, data, addr=None):
"""Write data to the transport, start a task to send the response"""
if self.retries:
self.retries -= 1
return
self.delayed_resp_task = asyncio.create_task(self.delayed_resp())

def close(self):
Expand All @@ -401,6 +405,35 @@ async def test_client_protocol_execute():
assert isinstance(response, pdu_bit_read.ReadCoilsResponse)


async def test_client_protocol_retry():
"""Test the client protocol execute method with retries"""
base = ModbusBaseClient(host="127.0.0.1", framer=ModbusSocketFramer, timeout=0.1)
request = pdu_bit_read.ReadCoilsRequest(1, 1)
transport = MockTransport(base, request, retries=2)
base.connection_made(transport=transport)

response = await base.async_execute(request)
assert transport.retries == 0
assert not response.isError()
assert isinstance(response, pdu_bit_read.ReadCoilsResponse)


async def test_client_protocol_timeout():
"""Test the client protocol execute method with timeout"""
base = ModbusBaseClient(
host="127.0.0.1", framer=ModbusSocketFramer, timeout=0.1, retries=2
)
# Avoid creating do_reconnect() task
base.connection_lost = mock.MagicMock()
request = pdu_bit_read.ReadCoilsRequest(1, 1)
transport = MockTransport(base, request, retries=4)
base.connection_made(transport=transport)

with pytest.raises(ModbusIOException):
await base.async_execute(request)
assert transport.retries == 1


def test_client_udp_connect():
"""Test the Udp client connection method"""
with mock.patch.object(socket, "socket") as mock_method:
Expand Down