Skip to content

Commit acdd9e9

Browse files
committed
Add ModbusTcpDiagClient
Synchronized TCP client that performs detail logging of network activity, for diagnosis of network related issues.
1 parent 79cdc5c commit acdd9e9

File tree

2 files changed

+315
-0
lines changed

2 files changed

+315
-0
lines changed

pymodbus/client/sync_diag.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import socket
2+
import logging
3+
import time
4+
5+
from pymodbus.constants import Defaults
6+
from pymodbus.client.sync import ModbusTcpClient
7+
from pymodbus.transaction import ModbusSocketFramer
8+
from pymodbus.exceptions import ConnectionException
9+
10+
_logger = logging.getLogger(__name__)
11+
12+
LOG_MSGS = {
13+
'conn_msg': 'Connecting to modbus device %s',
14+
'connfail_msg': 'Connection to (%s, %s) failed: %s',
15+
'discon_msg': 'Disconnecting from modbus device %s',
16+
'timelimit_read_msg':
17+
'Modbus device read took %.4f seconds, '
18+
'returned %s bytes in timelimit read',
19+
'timeout_msg':
20+
'Modbus device timeout after %.4f seconds, '
21+
'returned %s bytes %s',
22+
'delay_msg':
23+
'Modbus device read took %.4f seconds, '
24+
'returned %s bytes of %s expected',
25+
'read_msg':
26+
'Modbus device read took %.4f seconds, '
27+
'returned %s bytes of %s expected',
28+
'unexpected_dc_msg': '%s %s'}
29+
30+
31+
class ModbusTcpDiagClient(ModbusTcpClient):
32+
"""
33+
Variant of pymodbus.client.sync.ModbusTcpClient with additional
34+
logging to diagnose network issues.
35+
36+
The following events are logged:
37+
38+
+---------+-----------------------------------------------------------------+
39+
| Level | Events |
40+
+=========+=================================================================+
41+
| ERROR | Failure to connect to modbus unit; unexpected disconnect by |
42+
| | modbus unit |
43+
+---------+-----------------------------------------------------------------+
44+
| WARNING | Timeout on normal read; read took longer than warn_delay_limit |
45+
+---------+-----------------------------------------------------------------+
46+
| INFO | Connection attempt to modbus unit; disconnection from modbus |
47+
| | unit; each time limited read |
48+
+---------+-----------------------------------------------------------------+
49+
| DEBUG | Normal read with timing information |
50+
+---------+-----------------------------------------------------------------+
51+
52+
Reads are differentiated between "normal", which reads a specified number of
53+
bytes, and "time limited", which reads all data for a duration equal to the
54+
timeout period configured for this instance.
55+
"""
56+
57+
# pylint: disable=no-member
58+
59+
def __init__(self, host='127.0.0.1', port=Defaults.Port,
60+
framer=ModbusSocketFramer, **kwargs):
61+
""" Initialize a client instance
62+
63+
The keys of LOG_MSGS can be used in kwargs to customize the messages.
64+
65+
:param host: The host to connect to (default 127.0.0.1)
66+
:param port: The modbus port to connect to (default 502)
67+
:param source_address: The source address tuple to bind to (default ('', 0))
68+
:param timeout: The timeout to use for this socket (default Defaults.Timeout)
69+
:param warn_delay_limit: Log reads that take longer than this as warning.
70+
Default True sets it to half of "timeout". None never logs these as
71+
warning, 0 logs everything as warning.
72+
:param framer: The modbus framer to use (default ModbusSocketFramer)
73+
74+
.. note:: The host argument will accept ipv4 and ipv6 hosts
75+
"""
76+
self.warn_delay_limit = kwargs.get('warn_delay_limit', True)
77+
super().__init__(host, port, framer, **kwargs)
78+
if self.warn_delay_limit is True:
79+
self.warn_delay_limit = self.timeout / 2
80+
81+
# Set logging messages, defaulting to LOG_MSGS
82+
for (k, v) in LOG_MSGS.items():
83+
self.__dict__[k] = kwargs.get(k, v)
84+
85+
def connect(self):
86+
""" Connect to the modbus tcp server
87+
88+
:returns: True if connection succeeded, False otherwise
89+
"""
90+
if self.socket:
91+
return True
92+
try:
93+
_logger.info(self.conn_msg, self)
94+
self.socket = socket.create_connection(
95+
(self.host, self.port),
96+
timeout=self.timeout,
97+
source_address=self.source_address)
98+
except socket.error as msg:
99+
_logger.error(self.connfail_msg, self.host, self.port, msg)
100+
self.close()
101+
return self.socket is not None
102+
103+
def close(self):
104+
""" Closes the underlying socket connection
105+
"""
106+
if self.socket:
107+
_logger.info(self.discon_msg, self)
108+
self.socket.close()
109+
self.socket = None
110+
111+
def _recv(self, size):
112+
try:
113+
start = time.time()
114+
115+
result = super()._recv(size)
116+
117+
delay = time.time() - start
118+
if self.warn_delay_limit is not None and delay >= self.warn_delay_limit:
119+
result_len = len(result)
120+
if not size and result_len > 0:
121+
_logger.info(self.timelimit_read_msg, delay, result_len)
122+
elif (result_len == 0 or (size and result_len < size)) and delay >= self.timeout:
123+
read_type = ("of %i expected" % size) if size else "in timelimit read"
124+
_logger.warning(self.timeout_msg, delay, result_len, read_type)
125+
else:
126+
_logger.warning(self.delay_msg, delay, result_len, size)
127+
elif _logger.isEnabledFor(logging.DEBUG):
128+
result_len = len(result)
129+
if not size:
130+
_logger.debug(self.timelimit_read_msg, delay, result_len)
131+
else:
132+
_logger.debug(self.read_msg, delay, result_len, size)
133+
134+
return result
135+
except ConnectionException as ex:
136+
# Only log actual network errors, "if not self.socket" then it's a internal code issue
137+
if 'Connection unexpectedly closed' in ex.string:
138+
_logger.error(self.unexpected_dc_msg, self, ex)
139+
raise ex
140+
141+
def __str__(self):
142+
""" Builds a string representation of the connection
143+
144+
:returns: The string representation
145+
"""
146+
return "ModbusTcpDiagClient(%s:%s)" % (self.host, self.port)
147+
148+
149+
def get_client():
150+
""" Returns an appropriate client based on logging level
151+
152+
This will be ModbusTcpDiagClient by default, or the parent class
153+
if the log level is such that the diagnostic client will not log
154+
anything.
155+
156+
:returns: ModbusTcpClient or a child class thereof
157+
"""
158+
return ModbusTcpDiagClient if _logger.isEnabledFor(logging.ERROR) else ModbusTcpClient
159+
160+
161+
# --------------------------------------------------------------------------- #
162+
# Exported symbols
163+
# --------------------------------------------------------------------------- #
164+
165+
__all__ = [
166+
"ModbusTcpDiagClient", "get_client"
167+
]

test/test_client_sync_diag.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env python
2+
import unittest
3+
from pymodbus.compat import IS_PYTHON3
4+
5+
if IS_PYTHON3: # Python 3
6+
from unittest.mock import patch, Mock, MagicMock
7+
else: # Python 2
8+
from mock import patch, Mock, MagicMock
9+
import socket
10+
11+
from pymodbus.client.sync_diag import ModbusTcpDiagClient, get_client
12+
from pymodbus.exceptions import ConnectionException, NotImplementedException
13+
from pymodbus.exceptions import ParameterException
14+
15+
16+
# ---------------------------------------------------------------------------#
17+
# Mock Classes
18+
# ---------------------------------------------------------------------------#
19+
class mockSocket(object):
20+
timeout = 2
21+
def close(self): return True
22+
23+
def recv(self, size): return b'\x00' * size
24+
25+
def read(self, size): return b'\x00' * size
26+
27+
def send(self, msg): return len(msg)
28+
29+
def write(self, msg): return len(msg)
30+
31+
def recvfrom(self, size): return [b'\x00' * size]
32+
33+
def sendto(self, msg, *args): return len(msg)
34+
35+
def setblocking(self, flag): return None
36+
37+
def in_waiting(self): return None
38+
39+
40+
41+
# ---------------------------------------------------------------------------#
42+
# Fixture
43+
# ---------------------------------------------------------------------------#
44+
class SynchronousClientTest(unittest.TestCase):
45+
'''
46+
This is the unittest for the pymodbus.client.sync_diag module. It is
47+
a copy of the test for the TCP class in the pymodbus.client.sync module,
48+
as it should operate identically and only log some aditional lines.
49+
'''
50+
51+
# -----------------------------------------------------------------------#
52+
# Test TCP Diagnostic Client
53+
# -----------------------------------------------------------------------#
54+
55+
def testSyncTcpClientInstantiation(self):
56+
client = get_client()
57+
self.assertNotEqual(client, None)
58+
59+
@patch('pymodbus.client.sync.select')
60+
def testBasicSyncTcpClient(self, mock_select):
61+
''' Test the basic methods for the tcp sync diag client'''
62+
63+
# receive/send
64+
mock_select.select.return_value = [True]
65+
client = ModbusTcpDiagClient()
66+
client.socket = mockSocket()
67+
self.assertEqual(0, client._send(None))
68+
self.assertEqual(1, client._send(b'\x00'))
69+
self.assertEqual(b'\x00', client._recv(1))
70+
71+
# connect/disconnect
72+
self.assertTrue(client.connect())
73+
client.close()
74+
75+
# already closed socket
76+
client.socket = False
77+
client.close()
78+
79+
self.assertEqual("ModbusTcpDiagClient(127.0.0.1:502)", str(client))
80+
81+
def testTcpClientConnect(self):
82+
''' Test the tcp client connection method'''
83+
with patch.object(socket, 'create_connection') as mock_method:
84+
mock_method.return_value = object()
85+
client = ModbusTcpDiagClient()
86+
self.assertTrue(client.connect())
87+
88+
with patch.object(socket, 'create_connection') as mock_method:
89+
mock_method.side_effect = socket.error()
90+
client = ModbusTcpDiagClient()
91+
self.assertFalse(client.connect())
92+
93+
def testTcpClientSend(self):
94+
''' Test the tcp client send method'''
95+
client = ModbusTcpDiagClient()
96+
self.assertRaises(ConnectionException, lambda: client._send(None))
97+
98+
client.socket = mockSocket()
99+
self.assertEqual(0, client._send(None))
100+
self.assertEqual(4, client._send('1234'))
101+
102+
@patch('pymodbus.client.sync.select')
103+
def testTcpClientRecv(self, mock_select):
104+
''' Test the tcp client receive method'''
105+
106+
mock_select.select.return_value = [True]
107+
client = ModbusTcpDiagClient()
108+
self.assertRaises(ConnectionException, lambda: client._recv(1024))
109+
110+
client.socket = mockSocket()
111+
self.assertEqual(b'', client._recv(0))
112+
self.assertEqual(b'\x00' * 4, client._recv(4))
113+
114+
mock_socket = MagicMock()
115+
mock_socket.recv.side_effect = iter([b'\x00', b'\x01', b'\x02'])
116+
client.socket = mock_socket
117+
client.timeout = 1
118+
self.assertEqual(b'\x00\x01\x02', client._recv(3))
119+
mock_socket.recv.side_effect = iter([b'\x00', b'\x01', b'\x02'])
120+
self.assertEqual(b'\x00\x01', client._recv(2))
121+
mock_select.select.return_value = [False]
122+
self.assertEqual(b'', client._recv(2))
123+
client.socket = mockSocket()
124+
mock_select.select.return_value = [True]
125+
self.assertIn(b'\x00', client._recv(None))
126+
127+
def testSerialClientRpr(self):
128+
client = ModbusTcpDiagClient()
129+
rep = "<{} at {} socket={}, ipaddr={}, port={}, timeout={}>".format(
130+
client.__class__.__name__, hex(id(client)), client.socket,
131+
client.host, client.port, client.timeout
132+
)
133+
self.assertEqual(repr(client), rep)
134+
135+
def testTcpClientRegister(self):
136+
class CustomeRequest:
137+
function_code = 79
138+
client = ModbusTcpDiagClient()
139+
client.framer = Mock()
140+
client.register(CustomeRequest)
141+
assert client.framer.decoder.register.called_once_with(CustomeRequest)
142+
143+
144+
# ---------------------------------------------------------------------------#
145+
# Main
146+
# ---------------------------------------------------------------------------#
147+
if __name__ == "__main__":
148+
unittest.main()

0 commit comments

Comments
 (0)