8000 Update test framework to support smartcam device discovery. (#1477) · python-kasa/python-kasa@988eb96 · GitHub
[go: up one dir, main page]

Skip to content

Commit 988eb96

Browse files
authored
Update test framework to support smartcam device discovery. (#1477)
Update test framework to support `smartcam` device discovery: - Add `SMARTCAM` to the default `discovery_mock` filter - Make connection parameter derivation a self contained static method in `Discover` - Introduce a queue to the `discovery_mock` to ensure the discovery callbacks complete in the same order that they started. - Patch `Discover._decrypt_discovery_data` in `discovery_mock` so it doesn't error trying to decrypt empty fixture data
1 parent 5e57f8b commit 988eb96

File tree

3 files changed

+107
-62
lines changed

3 files changed

+107
-62
lines changed

kasa/discover.py

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,47 @@ def _get_discovery_json(data: bytes, ip: str) -> dict:
799799
) from ex
800800
return info
801801

802+
@staticmethod
803+
def _get_connection_parameters(
804+
discovery_result: DiscoveryResult,
805+
) -> DeviceConnectionParameters:
806+
"""Get connection parameters from the discovery result."""
807+
type_ = discovery_result.device_type
808+
if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None:
809+
raise UnsupportedDeviceError(
810+
f"Unsupported device {discovery_result.ip} of type {type_} "
811+
"with no mgt_encrypt_schm",
812+
discovery_result=discovery_result.to_dict(),
813+
host=discovery_result.ip,
814+
)
815+
816+
if not (encrypt_type := encrypt_schm.encrypt_type) and (
817+
encrypt_info := discovery_result.encrypt_info
818+
):
819+
encrypt_type = encrypt_info.sym_schm
820+
821+
if not (login_version := encrypt_schm.lv) and (
822+
et := discovery_result.encrypt_type
823+
):
824+
# Known encrypt types are ["1","2"] and ["3"]
825+
# Reuse the login_version attribute to pass the max to transport
826+
login_version = max([int(i) for i in et])
827+
828+
if not encrypt_type:
829+
raise UnsupportedDeviceError(
830+
f"Unsupported device {discovery_result.ip} of type {type_} "
831+
+ "with no encryption type",
832+
discovery_result=discovery_result.to_dict(),
833+
host=discovery_result.ip,
834+
)
835+
return DeviceConnectionParameters.from_values(
836+
type_,
837+
encrypt_type,
838+
login_version=login_version,
839+
https=encrypt_schm.is_support_https,
840+
http_port=encrypt_schm.http_port,
841+
)
842+
802843
@staticmethod
803844
def _get_device_instance(
804845
info: dict,
@@ -838,55 +879,22 @@ def _get_device_instance(
838879
config.host,
839880
redact_data(info, NEW_DISCOVERY_REDACTORS),
840881
)
841-
842882
type_ = discovery_result.device_type
843-
if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None:
844-
raise UnsupportedDeviceError(
845-
f"Unsupported device {config.host} of type {type_} "
846-
"with no mgt_encrypt_schm",
847-
discovery_result=discovery_result.to_dict(),
848-
host=config.host,
849-
)
850-
851883
try:
852-
if not (encrypt_type := encrypt_schm.encrypt_type) and (
853-
encrypt_info := discovery_result.encrypt_info
854-
):
855-
encrypt_type = encrypt_info.sym_schm
856-
857-
if not (login_version := encrypt_schm.lv) and (
858-
et := discovery_result.encrypt_type
859-
):
860-
# Known encrypt types are ["1","2"] and ["3"]
861-
# Reuse the login_version attribute to pass the max to transport
862-
login_version = max([int(i) for i in et])
863-
864-
if not encrypt_type:
865-
raise UnsupportedDeviceError(
866-
f"Unsupported device {config.host} of type {type_} "
867-
+ "with no encryption type",
868-
discovery_result=discovery_result.to_dict(),
869-
host=config.host,
870-
)
871-
config.connection_type = DeviceConnectionParameters.from_values(
872-
type_,
873-
encrypt_type,
874-
login_version=login_version,
875-
https=encrypt_schm.is_support_https,
876-
http_port=encrypt_schm.http_port,
877-
)
884+
conn_params = Discover._get_connection_parameters(discovery_result)
885+
config.connection_type = conn_params
878886
except KasaException as ex:
887+
if isinstance(ex, UnsupportedDeviceError):
888+
raise
879889
raise UnsupportedDeviceError(
880890
f"Unsupported device {config.host} of type {type_} "
881-
+ f"with encrypt_type {encrypt_schm.encrypt_type}",
891+
+ f"with encrypt_scheme {discovery_result.mgt_encrypt_schm}",
882892
discovery_result=discovery_result.to_dict(),
883893
host=config.host,
884894
) from ex
885895

886896
if (
887-
device_class := get_device_class_from_family(
888-
type_, https=encrypt_schm.is_support_https
889-
)
897+
device_class := get_device_class_from_family(type_, https=conn_params.https)
890898
) is None:
891899
_LOGGER.debug("Got unsupported device type: %s", type_)
892900
raise UnsupportedDeviceError(

tests/discovery_fixtures.py

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import copy
5+
from collections.abc import Coroutine
46
from dataclasses import dataclass
57
from json import dumps as json_dumps
68
from typing import Any, TypedDict
@@ -34,7 +36,7 @@ class DiscoveryResponse(TypedDict):
3436
"group_id": "REDACTED_07d902da02fa9beab8a64",
3537
"group_name": "I01BU0tFRF9TU0lEIw==", # '#MASKED_SSID#'
3638
"hardware_version": "3.0",
37-
"ip": "192.168.1.192",
39+
"ip": "127.0.0.1",
3840
"mac": "24:2F:D0:00:00:00",
3941
"master_device_id": "REDACTED_51f72a752213a6c45203530",
4042
"need_account_digest": True,
@@ -134,7 +136,9 @@ def parametrize_discovery(
134136

135137

136138
@pytest.fixture(
137-
params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}),
139+
params=filter_fixtures(
140+
"discoverable", protocol_filter={"SMART", "SMARTCAM", "IOT"}
141+
),
138142
ids=idgenerator,
139143
)
140144
async def discovery_mock(request, mocker):
@@ -251,12 +255,46 @@ def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker):
251255
first_ip = list(fixture_infos.keys())[0]
252256
first_host = None
253257

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
254289
async def mock_discover(self):
255290
"""Call datagram_received for all mock fixtures.
256291
257292
Handles test cases modifying the ip and hostname of the first fixture
258293
for discover_single testing.
259294
"""
295+
finished_event = asyncio.Event()
296+
asyncio.create_task(process_callback_queue(finished_event))
297+
260298
for ip, dm in discovery_mocks.items():
261299
first_ip = list(discovery_mocks.values())[0].ip
262300
fixture_info = fixture_infos[ip]
@@ -283,10 +321,18 @@ async def mock_discover(self):
283321
dm._datagram,
284322
(dm.ip, port),
285323
)
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)
286328

329+
# query_mock
287330
async def _query(self, request, retry_count: int = 3):
288331
return await protos[self._host].query(request)
289332

333+
mocker.patch("kasa.IotProtocol.query", _query)
334+
mocker.patch("kasa.SmartProtocol.query", _query)
335+
290336
def _getaddrinfo(host, *_, **__):
291337
nonlocal first_host, first_ip
292338
first_host = host # Store the hostname used by discover single
@@ -295,20 +341,21 @@ def _getaddrinfo(host, *_, **__):
295341
].ip # ip could have been overridden in test
296342
return [(None, None, None, None, (first_ip, 0))]
297343

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+
306351
# Only return the first discovery mock to be used for testing discover single
307352
return discovery_mocks[first_ip]
308353

309354

310355
@pytest.fixture(
311-
params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}),
356+
params=filter_fixtures(
357+
"discoverable", protocol_filter={"SMART", "SMARTCAM", "IOT"}
358+
),
312359
ids=idgenerator,
313360
)
314361
def discovery_data(request, mocker):

tests/test_device_factory.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,7 @@ def _get_connection_type_device_class(discovery_info):
6060
device_class = Discover._get_device_class(discovery_info)
6161
dr = DiscoveryResult.from_dict(discovery_info["result"])
6262

63-
connection_type = DeviceConnectionParameters.from_values(
64-
dr.device_type,
65-
dr.mgt_encrypt_schm.encrypt_type,
66-
login_version=dr.mgt_encrypt_schm.lv,
67-
https=dr.mgt_encrypt_schm.is_support_https,
68-
http_port=dr.mgt_encrypt_schm.http_port,
69-
)
63+
connection_type = Discover._get_connection_parameters(dr)
7064
else:
7165
connection_type = DeviceConnectionParameters.from_values(
7266
DeviceFamily.IotSmartPlugSwitch.value, DeviceEncryptionType.Xor.value
@@ -118,11 +112,7 @@ async def test_connect_custom_port(discovery_mock, mocker, custom_port):
118112
connection_type=ctype,
119113
credentials=Credentials("dummy_user", "dummy_password"),
120114
)
121-
default_port = (
122-
DiscoveryResult.from_dict(discovery_data["result"]).mgt_encrypt_schm.http_port
123-
if "result" in discovery_data
124-
else 9999
125-
)
115+
default_port = discovery_mock.default_port
126116

127117
ctype, _ = _get_connection_type_device_class(discovery_data)
128118

0 commit comments

Comments
 (0)
0