1
1
from __future__ import annotations
2
2
3
+ import asyncio
3
4
import copy
5
+ from collections .abc import Coroutine
4
6
from dataclasses import dataclass
5
7
from json import dumps as json_dumps
6
8
from typing import Any , TypedDict
@@ -34,7 +36,7 @@ class DiscoveryResponse(TypedDict):
34
36
"group_id" : "REDACTED_07d902da02fa9beab8a64" ,
35
37
"group_name" : "I01BU0tFRF9TU0lEIw==" , # '#MASKED_SSID#'
36
38
"hardware_version" : "3.0" ,
37
- "ip" : "192.168.1.192 " ,
39
+ "ip" : "127.0.0.1 " ,
38
40
"mac" : "24:2F:D0:00:00:00" ,
39
41
"master_device_id" : "REDACTED_51f72a752213a6c45203530" ,
40
42
"need_account_digest" : True ,
@@ -134,7 +136,9 @@ def parametrize_discovery(
134
136
135
137
136
138
@pytest .fixture (
137
- params = filter_fixtures ("discoverable" , protocol_filter = {"SMART" , "IOT" }),
139
+ params = filter_fixtures (
140
+ "discoverable" , protocol_filter = {"SMART" , "SMARTCAM" , "IOT" }
141
+ ),
138
142
ids = idgenerator ,
139
143
)
140
144
async def discovery_mock (request , mocker ):
@@ -251,12 +255,46 @@ def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker):
251
255
first_ip = list (fixture_infos .keys ())[0 ]
252
256
first_host = None
253
257
258
+ # Mock _run_callback_task so the tasks complete in the order they started.
259
+ # Otherwise test output is non-deterministic which affects readme examples.
260
+ callback_queue : asyncio .Queue = asyncio .Queue ()
261
+ exception_queue : asyncio .Queue = asyncio .Queue ()
262
+
263
+ async def process_callback_queue (finished_event : asyncio .Event ) -> None :
264
+ while (finished_event .is_set () is False ) or callback_queue .qsize ():
265
+ coro = await callback_queue .get ()
266
+ try :
267
+ await coro
268
+ except Exception as ex :
269
+ await exception_queue .put (ex )
270
+ else :
271
+ await exception_queue .put (None )
272
+ callback_queue .task_done ()
273
+
274
+ async def wait_for_coro ():
275
+ await callback_queue .join ()
276
+ if ex := exception_queue .get_nowait ():
277
+ raise ex
278
+
279
+ def _run_callback_task (self , coro : Coroutine ) -> None :
280
+ callback_queue .put_nowait (coro )
281
+ task = asyncio .create_task (wait_for_coro ())
282
+ self .callback_tasks .append (task )
283
+
284
+ mocker .patch (
285
+ "kasa.discover._DiscoverProtocol._run_callback_task" , _run_callback_task
286
+ )
287
+
288
+ # do_discover_mock
254
289
async def mock_discover (self ):
255
290
"""Call datagram_received for all mock fixtures.
256
291
257
292
Handles test cases modifying the ip and hostname of the first fixture
258
293
for discover_single testing.
259
294
"""
295
+ finished_event = asyncio .Event ()
296
+ asyncio .create_task (process_callback_queue (finished_event ))
297
+
260
298
for ip , dm in discovery_mocks .items ():
261
299
first_ip = list (discovery_mocks .values ())[0 ].ip
262
300
fixture_info = fixture_infos [ip ]
@@ -283,10 +321,18 @@ async def mock_discover(self):
283
321
dm ._datagram ,
284
322
(dm .ip , port ),
285
323
)
324
+ # Setting this event will stop the processing of callbacks
325
+ finished_event .set ()
326
+
327
+ mocker .patch ("kasa.discover._DiscoverProtocol.do_discover" , mock_discover )
286
328
329
+ # query_mock
287
330
async def _query (self , request , retry_count : int = 3 ):
288
331
return await protos [self ._host ].query (request )
289
332
333
+ mocker .patch ("kasa.IotProtocol.query" , _query )
334
+ mocker .patch ("kasa.SmartProtocol.query" , _query )
335
+
290
336
def _getaddrinfo (host , * _ , ** __ ):
291
337
nonlocal first_host , first_ip
292
338
first_host = host # Store the hostname used by discover single
@@ -295,20 +341,21 @@ def _getaddrinfo(host, *_, **__):
295
341
].ip # ip could have been overridden in test
296
342
return [(None , None , None , None , (first_ip , 0 ))]
297
343
298
- mocker .patch ("kasa.IotProtocol.query" , _query )
299
- mocker .patch ("kasa.SmartProtocol.query" , _query )
300
- mocker .patch ("kasa.discover._DiscoverProtocol.do_discover" , mock_discover )
301
- mocker .patch (
302
- "socket.getaddrinfo" ,
303
- # side_effect=lambda *_, **__: [(None, None, None, None, (first_ip, 0))],
304
- side_effect = _getaddrinfo ,
305
- )
344
+ mocker .patch ("socket.getaddrinfo" , side_effect = _getaddrinfo )
345
+
346
+ # Mock decrypt so it doesn't error with unencryptable empty data in the
347
+ # fixtures. The discovery result will already contain the decrypted data
348
+ # deserialized from the fixture
349
+ mocker .patch ("kasa.discover.Discover._decrypt_discovery_data" )
350
+
306
351
# Only return the first discovery mock to be used for testing discover single
307
352
return discovery_mocks [first_ip ]
308
353
309
354
310
355
@pytest .fixture (
311
- params = filter_fixtures ("discoverable" , protocol_filter = {"SMART" , "IOT" }),
356
+ params = filter_fixtures (
357
+ "discoverable" , protocol_filter = {"SMART" , "SMARTCAM" , "IOT" }
358
+ ),
312
359
ids = idgenerator ,
313
360
)
314
361
def discovery_data (request , mocker ):
0 commit comments