88import socket
99import ssl
1010import time
11+ import errno
12+
1113from unittest import TestCase , main
1214from unittest .mock import patch
15+ from unittest import mock
1316
1417import adafruit_minimqtt .adafruit_minimqtt as MQTT
1518
1619
20+ class Nulltet :
21+ """
22+ Mock Socket that does nothing.
23+
24+ Inspired by the Mocket class from Adafruit_CircuitPython_Requests
25+ """
26+
27+ def __init__ (self ):
28+ self .sent = bytearray ()
29+
30+ self .timeout = mock .Mock ()
31+ self .connect = mock .Mock ()
32+ self .close = mock .Mock ()
33+
34+ def send (self , bytes_to_send ):
35+ """
36+ Record the bytes. return the length of this bytearray.
37+ """
38+ self .sent .extend (bytes_to_send )
39+ return len (bytes_to_send )
40+
41+ # MiniMQTT checks for the presence of "recv_into" and switches behavior based on that.
42+ # pylint: disable=unused-argument,no-self-use
43+ def recv_into (self , retbuf , bufsize ):
44+ """Always raise timeout exception."""
45+ exc = OSError ()
46+ exc .errno = errno .ETIMEDOUT
47+ raise exc
48+
49+
50+ class Pingtet :
51+ """
52+ Mock Socket tailored for PINGREQ testing.
53+ Records sent data, hands out PINGRESP for each PINGREQ received.
54+
55+ Inspired by the Mocket class from Adafruit_CircuitPython_Requests
56+ """
57+
58+ PINGRESP = bytearray ([0xD0 , 0x00 ])
59+
60+ def __init__ (self ):
61+ self ._to_send = self .PINGRESP
62+
63+ self .sent = bytearray ()
64+
65+ self .timeout = mock .Mock ()
66+ self .connect = mock .Mock ()
67+ self .close = mock .Mock ()
68+
69+ self ._got_pingreq = False
70+
71+ def send (self , bytes_to_send ):
72+ """
73+ Recognize PINGREQ and record the indication that it was received.
74+ Assumes it was sent in one chunk (of 2 bytes).
75+ Also record the bytes. return the length of this bytearray.
76+ """
77+ self .sent .extend (bytes_to_send )
78+ if bytes_to_send == b"\xc0 \0 " :
79+ self ._got_pingreq = True
80+ return len (bytes_to_send )
81+
82+ # MiniMQTT checks for the presence of "recv_into" and switches behavior based on that.
83+ def recv_into (self , retbuf , bufsize ):
84+ """
85+ If the PINGREQ indication is on, return PINGRESP, otherwise raise timeout exception.
86+ """
87+ if self ._got_pingreq :
88+ size = min (bufsize , len (self ._to_send ))
89+ if size == 0 :
90+ return size
91+ chop = self ._to_send [0 :size ]
92+ retbuf [0 :] = chop
93+ self ._to_send = self ._to_send [size :]
94+ if len (self ._to_send ) == 0 :
95+ self ._got_pingreq = False
96+ self ._to_send = self .PINGRESP
97+ return size
98+
99+ exc = OSError ()
100+ exc .errno = errno .ETIMEDOUT
101+ raise exc
102+
103+
17104class Loop (TestCase ):
18105 """basic loop() test"""
19106
@@ -54,6 +141,8 @@ def test_loop_basic(self) -> None:
54141
55142 time_before = time .monotonic ()
56143 timeout = random .randint (3 , 8 )
144+ # pylint: disable=protected-access
145+ mqtt_client ._last_msg_sent_timestamp = mqtt_client .get_monotonic_time ()
57146 rcs = mqtt_client .loop (timeout = timeout )
58147 time_after = time .monotonic ()
59148
@@ -64,6 +153,7 @@ def test_loop_basic(self) -> None:
64153 assert rcs is not None
65154 assert len (rcs ) >= 1
66155 expected_rc = self .INITIAL_RCS_VAL
156+ # pylint: disable=not-an-iterable
67157 for ret_code in rcs :
68158 assert ret_code == expected_rc
69159 expected_rc += 1
@@ -104,6 +194,71 @@ def test_loop_is_connected(self):
104194
105195 assert "not connected" in str (context .exception )
106196
197+ # pylint: disable=no-self-use
198+ def test_loop_ping_timeout (self ):
199+ """Verify that ping will be sent even with loop timeout bigger than keep alive timeout
200+ and no outgoing messages are sent."""
201+
202+ recv_timeout = 2
203+ keep_alive_timeout = recv_timeout * 2
204+ mqtt_client = MQTT .MQTT (
205+ broker = "localhost" ,
206+ port = 1883 ,
207+ ssl_context = ssl .create_default_context (),
208+ connect_retries = 1 ,
209+ socket_timeout = 1 ,
210+ recv_timeout = recv_timeout ,
211+ keep_alive = keep_alive_timeout ,
212+ )
213+
214+ # patch is_connected() to avoid CONNECT/CONNACK handling.
215+ mqtt_client .is_connected = lambda : True
216+ mocket = Pingtet ()
217+ # pylint: disable=protected-access
218+ mqtt_client ._sock = mocket
219+
220+ start = time .monotonic ()
221+ res = mqtt_client .loop (timeout = 2 * keep_alive_timeout )
222+ assert time .monotonic () - start >= 2 * keep_alive_timeout
223+ assert len (mocket .sent ) > 0
224+ assert len (res ) == 2
225+ assert set (res ) == {int (0xD0 )}
226+
227+ # pylint: disable=no-self-use
228+ def test_loop_ping_vs_msgs_sent (self ):
229+ """Verify that ping will not be sent unnecessarily."""
230+
231+ recv_timeout = 2
232+ keep_alive_timeout = recv_timeout * 2
233+ mqtt_client = MQTT .MQTT (
234+ broker = "localhost" ,
235+ port = 1883 ,
236+ ssl_context = ssl .create_default_context (),
237+ connect_retries = 1 ,
238+ socket_timeout = 1 ,
239+ recv_timeout = recv_timeout ,
240+ keep_alive = keep_alive_timeout ,
241+ )
242+
243+ # patch is_connected() to avoid CONNECT/CONNACK handling.
244+ mqtt_client .is_connected = lambda : True
245+
246+ # With QoS=0 no PUBACK message is sent, so Nulltet can be used.
247+ mocket = Nulltet ()
248+ # pylint: disable=protected-access
249+ mqtt_client ._sock = mocket
250+
251+ i = 0
252+ topic = "foo"
253+ message = "bar"
254+ for _ in range (3 * keep_alive_timeout ):
255+ mqtt_client .publish (topic , message , qos = 0 )
256+ mqtt_client .loop (1 )
257+ i += 1
258+
259+ # This means no other messages than the PUBLISH messages generated by the code above.
260+ assert len (mocket .sent ) == i * (2 + 2 + len (topic ) + len (message ))
261+
107262
108263if __name__ == "__main__" :
109264 main ()
0 commit comments