Skip to content

Commit a0b401d

Browse files
committed
Split client base.
1 parent 33647e7 commit a0b401d

File tree

5 files changed

+308
-48
lines changed

5 files changed

+308
-48
lines changed

pymodbus/client/base.py

Lines changed: 284 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,285 @@ class ModbusBaseClient(ModbusClientMixin, ModbusProtocol):
3232
:param close_comm_on_error: Close connection on error.
3333
:param strict: Strict timing, 1.5 character between requests.
3434
:param broadcast_enable: True to treat id 0 as broadcast address.
35-
:param reconnect_delay: Minimum delay in milliseconds before reconnecting.
36-
:param reconnect_delay_max: Maximum delay in milliseconds before reconnecting.
35+
:param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting.
36+
:param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting.
37+
:param on_reconnect_callback: Function that will be called just before a reconnection attempt.
38+
:param no_resend_on_retry: Do not resend request when retrying due to missing response.
39+
:param kwargs: Experimental parameters.
40+
41+
.. tip::
42+
**reconnect_delay** doubles automatically with each unsuccessful connect, from
43+
**reconnect_delay** to **reconnect_delay_max**.
44+
Set `reconnect_delay=0` to avoid automatic reconnection.
45+
46+
:mod:`ModbusBaseClient` is normally not referenced outside :mod:`pymodbus`.
47+
48+
**Application methods, common to all clients**:
49+
"""
50+
51+
def __init__( # pylint: disable=too-many-arguments
52+
self,
53+
framer: Framer,
54+
timeout: float = 3,
55+
retries: int = 3,
56+
retry_on_empty: bool = False,
57+
close_comm_on_error: bool = False,
58+
strict: bool = True,
59+
broadcast_enable: bool = False,
60+
reconnect_delay: float = 0.1,
61+
reconnect_delay_max: float = 300,
62+
on_reconnect_callback: Callable[[], None] | None = None,
63+
no_resend_on_retry: bool = False,
64+
**kwargs: Any,
65+
) -> None:
66+
"""Initialize a client instance."""
67+
ModbusClientMixin.__init__(self)
68+
ModbusProtocol.__init__(
69+
self,
70+
CommParams(
71+
comm_type=kwargs.get("CommType"),
72+
comm_name="comm",
73+
source_address=kwargs.get("source_address", ("0.0.0.0", 0)),
74+
reconnect_delay=reconnect_delay,
75+
reconnect_delay_max=reconnect_delay_max,
76+
timeout_connect=timeout,
77+
host=kwargs.get("host", None),
78+
port=kwargs.get("port", 0),
79+
sslctx=kwargs.get("sslctx", None),
80+
baudrate=kwargs.get("baudrate", None),
81+
bytesize=kwargs.get("bytesize", None),
82+
parity=kwargs.get("parity", None),
83+
stopbits=kwargs.get("stopbits", None),
84+
handle_local_echo=kwargs.get("handle_local_echo", False),
85+
),
86+
False,
87+
)
88+
self.on_reconnect_callback = on_reconnect_callback
89+
self.retry_on_empty: int = 0
90+
self.no_resend_on_retry = no_resend_on_retry
91+
self.slaves: list[int] = []
92+
self.retries: int = retries
93+
self.broadcast_enable = broadcast_enable
94+
95+
# Common variables.
96+
self.framer = FRAMER_NAME_TO_CLASS.get(
97+
framer, cast(Type[ModbusFramer], framer)
98+
)(ClientDecoder(), self)
99+
self.transaction = DictTransactionManager(
100+
self, retries=retries, retry_on_empty=retry_on_empty, **kwargs
101+
)
102+
self.use_udp = False
103+
self.state = ModbusTransactionState.IDLE
104+
self.last_frame_end: float | None = 0
105+
self.silent_interval: float = 0
106+
107+
# ----------------------------------------------------------------------- #
108+
# Client external interface
109+
# ----------------------------------------------------------------------- #
110+
@property
111+
def connected(self) -> bool:
112+
"""Return state of connection."""
113+
return self.is_active()
114+
115+
def register(self, custom_response_class: ModbusResponse) -> None:
116+
"""Register a custom response class with the decoder (call **sync**).
117+
118+
:param custom_response_class: (optional) Modbus response class.
119+
:raises MessageRegisterException: Check exception text.
120+
121+
Use register() to add non-standard responses (like e.g. a login prompt) and
122+
have them interpreted automatically.
123+
"""
124+
self.framer.decoder.register(custom_response_class)
125+
126+
def close(self, reconnect: bool = False) -> None:
127+
"""Close connection."""
128+
if reconnect:
129+
self.connection_lost(asyncio.TimeoutError("Server not responding"))
130+
else:
131+
self.transport_close()
132+
133+
def idle_time(self) -> float:
134+
"""Time before initiating next transaction (call **sync**).
135+
136+
Applications can call message functions without checking idle_time(),
137+
this is done automatically.
138+
"""
139+
if self.last_frame_end is None or self.silent_interval is None:
140+
return 0
141+
return self.last_frame_end + self.silent_interval
142+
143+
def execute(self, request: ModbusRequest | None = None) -> ModbusResponse:
144+
"""Execute request and get response (call **sync/async**).
145+
146+
:param request: The request to process
147+
:returns: The result of the request execution
148+
:raises ConnectionException: Check exception text.
149+
"""
150+
if not self.transport:
151+
raise ConnectionException(f"Not connected[{self!s}]")
152+
return self.async_execute(request)
153+
154+
# ----------------------------------------------------------------------- #
155+
# Merged client methods
156+
# ----------------------------------------------------------------------- #
157+
async def async_execute(self, request=None):
158+
"""Execute requests asynchronously."""
159+
request.transaction_id = self.transaction.getNextTID()
160+
packet = self.framer.buildPacket(request)
161+
162+
count = 0
163+
while count <= self.retries:
164+
if not count or not self.no_resend_on_retry:
165+
self.transport_send(packet)
166+
if self.broadcast_enable and not request.slave_id:
167+
resp = b"Broadcast write sent - no response expected"
168+
break
169+
try:
170+
req = self._build_response(request.transaction_id)
171+
resp = await asyncio.wait_for(
172+
req, timeout=self.comm_params.timeout_connect
173+
)
174+
break
175+
except asyncio.exceptions.TimeoutError:
176+
count += 1
177+
if count > self.retries:
178+
self.close(reconnect=True)
179+
raise ModbusIOException(
180+
f"ERROR: No response received after {self.retries} retries"
181+
)
182+
183+
return resp
184+
185+
def callback_data(self, data: bytes, addr: tuple | None = None) -> int:
186+
"""Handle received data.
187+
188+
returns number of bytes consumed
189+
"""
190+
self.framer.processIncomingPacket(data, self._handle_response, slave=0)
191+
return len(data)
192+
193+
def callback_disconnected(self, _reason: Exception | None) -> None:
194+
"""Handle lost connection."""
195+
for tid in list(self.transaction):
196+
self.raise_future(
197+
self.transaction.getTransaction(tid),
198+
ConnectionException("Connection lost during request"),
199+
)
200+
201+
async def connect(self):
202+
"""Connect to the modbus remote host."""
203+
204+
def raise_future(self, my_future, exc):
205+
"""Set exception of a future if not done."""
206+
if not my_future.done():
207+
my_future.set_exception(exc)
208+
209+
def _handle_response(self, reply, **_kwargs):
210+
"""Handle the processed response and link to correct deferred."""
211+
if reply is not None:
212+
tid = reply.transaction_id
213+
if handler := self.transaction.getTransaction(tid):
214+
if not handler.done():
215+
handler.set_result(reply)
216+
else:
217+
Log.debug("Unrequested message: {}", reply, ":str")
218+
219+
def _build_response(self, tid):
220+
"""Return a deferred response for the current request."""
221+
my_future = asyncio.Future()
222+
if not self.transport:
223+
self.raise_future(my_future, ConnectionException("Client is not connected"))
224+
else:
225+
self.transaction.addTransaction(my_future, tid)
226+
return my_future
227+
228+
# ----------------------------------------------------------------------- #
229+
# Internal methods
230+
# ----------------------------------------------------------------------- #
231+
def send(self, request):
232+
"""Send request.
233+
234+
:meta private:
235+
"""
236+
if self.state != ModbusTransactionState.RETRYING:
237+
Log.debug('New Transaction state "SENDING"')
238+
self.state = ModbusTransactionState.SENDING
239+
return request
240+
241+
def recv(self, size):
242+
"""Receive data.
243+
244+
:meta private:
245+
"""
246+
return size
247+
248+
@classmethod
249+
def _get_address_family(cls, address):
250+
"""Get the correct address family."""
251+
try:
252+
_ = socket.inet_pton(socket.AF_INET6, address)
253+
except OSError: # not a valid ipv6 address
254+
return socket.AF_INET
255+
return socket.AF_INET6
256+
257+
# ----------------------------------------------------------------------- #
258+
# The magic methods
259+
# ----------------------------------------------------------------------- #
260+
def __enter__(self):
261+
"""Implement the client with enter block.
262+
263+
:returns: The current instance of the client
264+
:raises ConnectionException:
265+
"""
266+
if not self.connect():
267+
raise ConnectionException(f"Failed to connect[{self.__str__()}]")
268+
return self
269+
270+
async def __aenter__(self):
271+
"""Implement the client with enter block.
272+
273+
:returns: The current instance of the client
274+
:raises ConnectionException:
275+
"""
276+
if not await self.connect():
277+
raise ConnectionException(f"Failed to connect[{self.__str__()}]")
278+
return self
279+
280+
def __exit__(self, klass, value, traceback):
281+
"""Implement the client with exit block."""
282+
self.close()
283+
284+
async def __aexit__(self, klass, value, traceback):
285+
"""Implement the client with exit block."""
286+
self.close()
287+
288+
def __str__(self):
289+
"""Build a string representation of the connection.
290+
291+
:returns: The string representation
292+
"""
293+
return (
294+
f"{self.__class__.__name__} {self.comm_params.host}:{self.comm_params.port}"
295+
)
296+
297+
class ModbusBaseSyncClient(ModbusClientMixin, ModbusProtocol):
298+
"""**ModbusBaseClient**.
299+
300+
Fixed parameters:
301+
302+
:param framer: Framer enum name
303+
304+
Optional parameters:
305+
306+
:param timeout: Timeout for a request, in seconds.
307+
:param retries: Max number of retries per request.
308+
:param retry_on_empty: Retry on empty response.
309+
:param close_comm_on_error: Close connection on error.
310+
:param strict: Strict timing, 1.5 character between requests.
311+
:param broadcast_enable: True to treat id 0 as broadcast address.
312+
:param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting.
313+
:param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting.
37314
:param on_reconnect_callback: Function that will be called just before a reconnection attempt.
38315
:param no_resend_on_retry: Do not resend request when retrying due to missing response.
39316
:param kwargs: Experimental parameters.
@@ -73,15 +350,14 @@ def __init__( # pylint: disable=too-many-arguments
73350
strict: bool = True,
74351
broadcast_enable: bool = False,
75352
reconnect_delay: float = 0.1,
76-
reconnect_delay_max: float = 300,
353+
reconnect_delay_max: float = 300.0,
77354
on_reconnect_callback: Callable[[], None] | None = None,
78355
no_resend_on_retry: bool = False,
79356
**kwargs: Any,
80357
) -> None:
81358
"""Initialize a client instance."""
82359
ModbusClientMixin.__init__(self)
83-
self.use_sync = kwargs.get("use_sync", False)
84-
setup_params = CommParams(
360+
self.comm_params = CommParams(
85361
comm_type=kwargs.get("CommType"),
86362
comm_name="comm",
87363
source_address=kwargs.get("source_address", ("0.0.0.0", 0)),
@@ -97,14 +373,6 @@ def __init__( # pylint: disable=too-many-arguments
97373
stopbits=kwargs.get("stopbits", None),
98374
handle_local_echo=kwargs.get("handle_local_echo", False),
99375
)
100-
if not self.use_sync:
101-
ModbusProtocol.__init__(
102-
self,
103-
setup_params,
104-
False,
105-
)
106-
else:
107-
self.comm_params = setup_params
108376
self.params = self._params()
109377
self.params.retries = int(retries)
110378
self.params.retry_on_empty = bool(retry_on_empty)
@@ -172,13 +440,9 @@ def execute(self, request: ModbusRequest | None = None) -> ModbusResponse:
172440
:returns: The result of the request execution
173441
:raises ConnectionException: Check exception text.
174442
"""
175-
if self.use_sync:
176-
if not self.connect():
177-
raise ConnectionException(f"Failed to connect[{self!s}]")
178-
return self.transaction.execute(request)
179-
if not self.transport:
180-
raise ConnectionException(f"Not connected[{self!s}]")
181-
return self.async_execute(request)
443+
if not self.connect():
444+
raise ConnectionException(f"Failed to connect[{self!s}]")
445+
return self.transaction.execute(request)
182446

183447
# ----------------------------------------------------------------------- #
184448
# Merged client methods

pymodbus/client/serial.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from functools import partial
77
from typing import Any
88

9-
from pymodbus.client.base import ModbusBaseClient
9+
from pymodbus.client.base import ModbusBaseClient, ModbusBaseSyncClient
1010
from pymodbus.exceptions import ConnectionException
1111
from pymodbus.framer import Framer
1212
from pymodbus.logging import Log
@@ -46,8 +46,8 @@ class AsyncModbusSerialClient(ModbusBaseClient, asyncio.Protocol):
4646
:param close_comm_on_error: Close connection on error.
4747
:param strict: Strict timing, 1.5 character between requests.
4848
:param broadcast_enable: True to treat id 0 as broadcast address.
49-
:param reconnect_delay: Minimum delay in milliseconds before reconnecting.
50-
:param reconnect_delay_max: Maximum delay in milliseconds before reconnecting.
49+
:param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting.
50+
:param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting.
5151
:param on_reconnect_callback: Function that will be called just before a reconnection attempt.
5252
:param no_resend_on_retry: Do not resend request when retrying due to missing response.
5353
:param kwargs: Experimental parameters.
@@ -106,7 +106,7 @@ def close(self, reconnect: bool = False) -> None:
106106
super().close(reconnect=reconnect)
107107

108108

109-
class ModbusSerialClient(ModbusBaseClient):
109+
class ModbusSerialClient(ModbusBaseSyncClient):
110110
"""**ModbusSerialClient**.
111111
112112
Fixed parameters:
@@ -130,8 +130,8 @@ class ModbusSerialClient(ModbusBaseClient):
130130
:param close_comm_on_error: Close connection on error.
131131
:param strict: Strict timing, 1.5 character between requests.
132132
:param broadcast_enable: True to treat id 0 as broadcast address.
133-
:param reconnect_delay: Minimum delay in milliseconds before reconnecting.
134-
:param reconnect_delay_max: Maximum delay in milliseconds before reconnecting.
133+
:param reconnect_delay: Minimum delay in seconds.milliseconds before reconnecting.
134+
:param reconnect_delay_max: Maximum delay in seconds.milliseconds before reconnecting.
135135
:param on_reconnect_callback: Function that will be called just before a reconnection attempt.
136136
:param no_resend_on_retry: Do not resend request when retrying due to missing response.
137137
:param kwargs: Experimental parameters.
@@ -167,7 +167,6 @@ def __init__(
167167
**kwargs: Any,
168168
) -> None:
169169
"""Initialize Modbus Serial Client."""
170-
kwargs["use_sync"] = True
171170
super().__init__(
172171
framer,
173172
CommType=CommType.SERIAL,

0 commit comments

Comments
 (0)