@@ -57,6 +57,7 @@ class TCPModbusClient:
57
57
KEEPALIVE_MAX_FAILS : ClassVar = 5
58
58
59
59
PING_LOOP_PERIOD : ClassVar = 1
60
+ CONSECUTIVE_TIMEOUTS_TO_RECONNECT : ClassVar = 5
60
61
61
62
def __init__ (
62
63
self ,
@@ -66,11 +67,13 @@ def __init__(
66
67
* ,
67
68
logger : logging .Logger | None = None ,
68
69
enforce_pingable : bool = True ,
70
+ ping_timeout : float = 0.5 ,
69
71
) -> None :
70
72
self .host = host
71
73
self .port = port
72
74
self .slave_id = slave_id
73
75
self .logger = logger
76
+ self .ping_timeout = ping_timeout
74
77
75
78
# If True, will throw an exception if attempting to send a request and the device is not pingable
76
79
self .enforce_pingable = enforce_pingable
@@ -82,6 +85,9 @@ def __init__(
82
85
self ._reader : asyncio .StreamReader | None = None
83
86
self ._writer : asyncio .StreamWriter | None = None
84
87
88
+ # Number of current consecutive modbus calls that resulted in a timeout
89
+ self ._consecutive_timeouts = 0
90
+
85
91
# Last ping time in seconds from ping loop, or None if the last ping failed
86
92
self ._last_ping : float | None = None
87
93
@@ -132,7 +138,7 @@ def __repr__(self) -> str:
132
138
133
139
async def _ping_loop_task (self ) -> None :
134
140
while True :
135
- self ._last_ping = await ping_ip (self .host )
141
+ self ._last_ping = await ping_ip (self .host , timeout = self . ping_timeout )
136
142
137
143
if self .logger is not None :
138
144
self .logger .debug (f"[{ self } ][_ping_loop_task] ping ping ping" )
@@ -143,67 +149,74 @@ async def _ping_loop_task(self) -> None:
143
149
async def _get_tcp_connection (
144
150
self , timeout : float | None = DEFAULT_MODBUS_TIMEOUT_SEC
145
151
) -> tuple [asyncio .StreamReader , asyncio .StreamWriter ]:
146
- if self ._reader is None or self ._writer is None :
147
- self ._lifetime_tcp_connection_num += 1
152
+ if self ._reader is not None and self ._writer is not None :
153
+ return self ._reader , self . _writer
148
154
149
- if self .logger is not None :
150
- self .logger .info (
151
- f"[{ self } ][_get_tcp_connection] creating new TCP connection (#{ self ._lifetime_tcp_connection_num } )"
152
- )
155
+ self ._lifetime_tcp_connection_num += 1
153
156
154
- try :
155
- reader , writer = await asyncio . wait_for (
156
- asyncio . open_connection ( host = self .host , port = self . port ), timeout
157
- )
157
+ if self . logger is not None :
158
+ self . logger . info (
159
+ f"[ { self } ][_get_tcp_connection] creating new TCP connection (# { self ._lifetime_tcp_connection_num } )"
160
+ )
158
161
159
- sock : socket .socket = writer .get_extra_info ("socket" )
160
-
161
- # Receive and send buffers set to 900 bytes (recommended by MODBUS implementation guide: this is
162
- # becuase the max request size is 256 bytes + the header size of 7 bytes = 263 bytes, and the
163
- # max response size is 256 bytes + the header size of 7 bytes = 263 bytes, so a 900 byte buffer
164
- # can store 3 frames of buffering, which is apparently the suggestion).
165
- sock .setsockopt (socket .SOL_SOCKET , socket .SO_RCVBUF , 900 )
166
- sock .setsockopt (socket .SOL_SOCKET , socket .SO_SNDBUF , 900 )
167
-
168
- # Reuse address (perf optimization, recommended by MODBUS implementation guide)
169
- sock .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
170
-
171
- # Enable TCP_NODELAY (prevent small packet buffering, recommended by MODBUS implementation guide)
172
- sock .setsockopt (socket .IPPROTO_TCP , socket .TCP_NODELAY , 1 )
173
-
174
- # Enable TCP keepalive (otherwise the Adam connection will terminate after 720 (1000?) seconds
175
- # with an open idle connection: this is also recommended by the MODBUS implementation guide)
176
- #
177
- # In most cases this is not necessary because Adam commands are short lived and we
178
- # close the connection after each command. However, if we want to keep a connection
179
- # open for a long time we would need to enable keepalive.
180
-
181
- sock .setsockopt (socket .SOL_SOCKET , socket .SO_KEEPALIVE , 1 )
182
- if hasattr (socket , "TCP_KEEPIDLE" ):
183
- # Only available on Linux so this makes typing work cross platform
184
- sock .setsockopt (
185
- socket .IPPROTO_TCP ,
186
- socket .TCP_KEEPIDLE ,
187
- self .KEEPALIVE_AFTER_IDLE_SEC ,
188
- )
162
+ try :
163
+ reader , writer = await asyncio .wait_for (
164
+ asyncio .open_connection (host = self .host , port = self .port ), timeout
165
+ )
166
+ except asyncio .TimeoutError :
167
+ msg = (
168
+ f"Timed out connecting to TCP modbus device at { self .host } :{ self .port } "
169
+ )
170
+ if self .logger is not None :
171
+ self .logger .warning (f"[{ self } ][_get_tcp_connection] { msg } " )
172
+ raise ModbusCommunicationTimeoutError (msg )
173
+ except OSError :
174
+ msg = f"Cannot connect to TCP modbus device at { self .host } :{ self .port } "
175
+ if self .logger is not None :
176
+ self .logger .warning (f"[{ self } ][_get_tcp_connection] { msg } " )
177
+ raise ModbusNotConnectedError (msg )
178
+
179
+ sock : socket .socket = writer .get_extra_info ("socket" )
180
+
181
+ # Receive and send buffers set to 900 bytes (recommended by MODBUS implementation guide: this is
182
+ # becuase the max request size is 256 bytes + the header size of 7 bytes = 263 bytes, and the
183
+ # max response size is 256 bytes + the header size of 7 bytes = 263 bytes, so a 900 byte buffer
184
+ # can store 3 frames of buffering, which is apparently the suggestion).
185
+ sock .setsockopt (socket .SOL_SOCKET , socket .SO_RCVBUF , 900 )
186
+ sock .setsockopt (socket .SOL_SOCKET , socket .SO_SNDBUF , 900 )
187
+
188
+ # Reuse address (perf optimization, recommended by MODBUS implementation guide)
189
+ sock .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
190
+
191
+ # Enable TCP_NODELAY (prevent small packet buffering, recommended by MODBUS implementation guide)
192
+ sock .setsockopt (socket .IPPROTO_TCP , socket .TCP_NODELAY , 1 )
193
+
194
+ # Enable TCP keepalive (otherwise the Adam connection will terminate after 720 (1000?) seconds
195
+ # with an open idle connection: this is also recommended by the MODBUS implementation guide)
196
+ #
197
+ # In most cases this is not necessary because Adam commands are short lived and we
198
+ # close the connection after each command. However, if we want to keep a connection
199
+ # open for a long time we would need to enable keepalive.
200
+
201
+ sock .setsockopt (socket .SOL_SOCKET , socket .SO_KEEPALIVE , 1 )
202
+ if hasattr (socket , "TCP_KEEPIDLE" ):
203
+ # Only available on Linux so this makes typing work cross platform
204
+ sock .setsockopt (
205
+ socket .IPPROTO_TCP ,
206
+ socket .TCP_KEEPIDLE ,
207
+ self .KEEPALIVE_AFTER_IDLE_SEC ,
208
+ )
189
209
190
- sock .setsockopt (
191
- socket .IPPROTO_TCP ,
192
- socket .TCP_KEEPINTVL ,
193
- self .KEEPALIVE_INTERVAL_SEC ,
194
- )
195
- sock .setsockopt (
196
- socket .IPPROTO_TCP , socket .TCP_KEEPCNT , self .KEEPALIVE_MAX_FAILS
197
- )
210
+ sock .setsockopt (
211
+ socket .IPPROTO_TCP ,
212
+ socket .TCP_KEEPINTVL ,
213
+ self .KEEPALIVE_INTERVAL_SEC ,
214
+ )
215
+ sock .setsockopt (
216
+ socket .IPPROTO_TCP , socket .TCP_KEEPCNT , self .KEEPALIVE_MAX_FAILS
217
+ )
198
218
199
- self ._reader , self ._writer = reader , writer
200
- except (asyncio .TimeoutError , OSError ):
201
- msg = f"Cannot connect to TCP modbus device at { self .host } :{ self .port } "
202
- if self .logger is not None :
203
- self .logger .warning (f"[{ self } ][_get_tcp_connection] { msg } " )
204
- raise ModbusNotConnectedError (msg )
205
- else :
206
- reader , writer = self ._reader , self ._writer
219
+ self ._reader , self ._writer = reader , writer
207
220
208
221
return reader , writer
209
222
@@ -226,7 +239,7 @@ async def close(self) -> None:
226
239
if self ._ping_loop is None :
227
240
return
228
241
229
- await self .clear_tcp_connection ()
242
+ self .clear_tcp_connection ()
230
243
231
244
if self ._ping_loop is not None :
232
245
if self .logger is not None :
@@ -302,12 +315,14 @@ async def _watch_loop() -> None:
302
315
_watch_loop (), name = f"TCPModbusClient{ log_prefix } "
303
316
)
304
317
305
- async def clear_tcp_connection (self ) -> None :
318
+ def clear_tcp_connection (self ) -> None :
306
319
"""
307
320
Closes the current TCP connection and clears the reader and writer objects.
308
321
On the next send_modbus_message call, a new connection will be created.
309
322
"""
310
323
324
+ self ._consecutive_timeouts = 0
325
+
311
326
if self ._ping_loop is None :
312
327
raise RuntimeError ("Cannot clear TCP connection on closed TCPModbusClient" )
313
328
@@ -319,17 +334,6 @@ async def clear_tcp_connection(self) -> None:
319
334
320
335
self ._writer .close ()
321
336
322
- try :
323
- await self ._writer .wait_closed ()
324
- except (TimeoutError , ConnectionResetError , OSError ) as e :
325
- if self .logger is not None :
326
- self .logger .warning (
327
- f"[{ self } ][clear_tcp_connection] { type (e ).__name__ } ({ e } ) error on connection close, "
328
- "continuing anyway"
329
- )
330
-
331
- pass
332
-
333
337
self ._reader = None
334
338
self ._writer = None
335
339
@@ -434,6 +438,7 @@ async def send_modbus_message(
434
438
reader , writer = await self ._get_tcp_connection (
435
439
timeout = time_budget_remaining
436
440
)
441
+
437
442
time_budget_remaining -= conn_t ()
438
443
439
444
# STEP THREE: WRITE OUR REQUEST
@@ -447,9 +452,9 @@ async def send_modbus_message(
447
452
if self .logger is not None :
448
453
self .logger .debug (f"[{ self } ][send_modbus_message] wrote { msg_str } " )
449
454
450
- except ( asyncio . TimeoutError , OSError , ConnectionResetError ):
455
+ except OSError : # this includes timeout errors
451
456
# Clear connection no matter what if we fail on the write
452
- # TODO: consider revisiting this to only do it on OSError and ConnectionResetError
457
+ # TODO: consider revisiting this to not do it on a timeouterror
453
458
# (but Gru is scared about partial writes)
454
459
455
460
if self .logger is not None :
@@ -458,7 +463,7 @@ async def send_modbus_message(
458
463
f"request { msg_str } , clearing connection"
459
464
)
460
465
461
- await self .clear_tcp_connection ()
466
+ self .clear_tcp_connection ()
462
467
463
468
if retries > 0 :
464
469
if self .logger is not None :
@@ -516,25 +521,37 @@ async def send_modbus_message(
516
521
return None
517
522
518
523
raise
524
+ except asyncio .TimeoutError as e :
525
+ self ._consecutive_timeouts += 1
526
+ if self ._consecutive_timeouts >= self .CONSECUTIVE_TIMEOUTS_TO_RECONNECT :
527
+ if self .logger is not None :
528
+ self .logger .warning (
529
+ f"[{ self } ][send_modbus_message] { self ._consecutive_timeouts } consecutive timeouts, "
530
+ "clearing connection"
531
+ )
532
+ self .clear_tcp_connection ()
519
533
520
- except (asyncio .TimeoutError , OSError , ConnectionResetError ) as e :
521
- # We clear the connection if the connection was reset by peer or was an OS error
522
- if isinstance (e , (OSError , ConnectionResetError )):
523
- print ("CLEARING TCP ON GENERAL FAIL" )
524
- await self .clear_tcp_connection ()
525
-
526
- raise (
527
- ModbusCommunicationTimeoutError
528
- if isinstance (e , asyncio .TimeoutError )
529
- else ModbusCommunicationFailureError
530
- )(
531
- f"Request { msg_str } failed to { self .host } :{ self .port } ({ type (e ).__name__ } ({ e } ))"
534
+ raise ModbusCommunicationTimeoutError (
535
+ f"Request { msg_str } timed out to { self .host } :{ self .port } "
532
536
) from e
537
+ except OSError as e :
538
+ if self .logger is not None :
539
+ self .logger .warning (
540
+ f"[{ self } ][send_modbus_message] OSError{ type (e ).__name__ } ({ e } ) while sending request { msg_str } , "
541
+ "clearing connection"
542
+ )
533
543
544
+ self .clear_tcp_connection ()
545
+
546
+ raise ModbusCommunicationFailureError (
547
+ f"Request { msg_str } failed to { self .host } :{ self .port } ({ type (e ).__name__ } ({ e } ))"
548
+ ) from e
534
549
finally :
535
550
if self ._comms_lock .locked ():
536
551
self ._comms_lock .release ()
537
552
553
+ self ._consecutive_timeouts = 0
554
+
538
555
if self .logger is not None :
539
556
self .logger .debug (
540
557
f"[{ self } ][send_modbus_message] executed request/response with timing "
0 commit comments