From 027d9b43455fa7b9985743912e38d4fbe290dd9a Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 20 Jan 2025 19:04:38 +0000 Subject: [PATCH 1/6] Add childsetup module to smartcam hubs --- kasa/smartcam/modules/__init__.py | 2 + kasa/smartcam/modules/childsetup.py | 99 ++++++++++++++++++++ kasa/smartcam/smartcamdevice.py | 17 +++- kasa/smartcam/smartcammodule.py | 18 +--- tests/fakeprotocol_smartcam.py | 47 +++++++++- tests/smartcam/modules/test_childsetup.py | 106 ++++++++++++++++++++++ tests/test_cli.py | 6 +- tests/test_feature.py | 12 +-- 8 files changed, 279 insertions(+), 28 deletions(-) create mode 100644 kasa/smartcam/modules/childsetup.py create mode 100644 tests/smartcam/modules/test_childsetup.py diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py index 14bd24f1e..4f6ed866a 100644 --- a/kasa/smartcam/modules/__init__.py +++ b/kasa/smartcam/modules/__init__.py @@ -5,6 +5,7 @@ from .battery import Battery from .camera import Camera from .childdevice import ChildDevice +from .childsetup import ChildSetup from .device import DeviceModule from .homekit import HomeKit from .led import Led @@ -23,6 +24,7 @@ "Battery", "Camera", "ChildDevice", + "ChildSetup", "DeviceModule", "Led", "PanTilt", diff --git a/kasa/smartcam/modules/childsetup.py b/kasa/smartcam/modules/childsetup.py new file mode 100644 index 000000000..4e768762c --- /dev/null +++ b/kasa/smartcam/modules/childsetup.py @@ -0,0 +1,99 @@ +"""Implementation for child device setup. + +This module allows pairing and disconnecting child devices. +""" + +from __future__ import annotations + +import asyncio +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class ChildSetup(SmartCamModule): + """Implementation for child device setup.""" + + REQUIRED_COMPONENT = "childQuickSetup" + QUERY_GETTER_NAME = "getSupportChildDeviceCategory" + QUERY_MODULE_NAME = "childControl" + _categories: list[str] = [] + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="pair", + name="Pair", + container=self, + attribute_setter="pair", + category=Feature.Category.Config, + type=Feature.Type.Action, + ) + ) + + async def _post_update_hook(self) -> None: + if not self._categories: + self._categories = [ + cat["category"].replace("ipcamera", "camera") + for cat in self.data["device_category_list"] + ] + + async def pair(self, *, timeout: int = 10) -> list[dict]: + """Scan for new devices and pair after discovering first new device.""" + await self.call( + "startScanChildDevice", {"childControl": {"category": self._categories}} + ) + + _LOGGER.info("Waiting %s seconds for discovering new devices", timeout) + + await asyncio.sleep(timeout) + res = await self.call( + "getScanChildDeviceList", {"childControl": {"category": self._categories}} + ) + + detected_list = res["getScanChildDeviceList"]["child_device_list"] + if not detected_list: + detected_list = await self.call( + "getScanChildDeviceList", + {"childControl": {"category": self._categories}}, + ) + _LOGGER.info("No devices found.") + return [] + + _LOGGER.info( + "Discovery done, found %s devices: %s", + len(detected_list), + detected_list, + ) + + await self.call( + "addScanChildDeviceList", + {"childControl": {"child_device_list": detected_list}}, + ) + + await self._device.update() + + successes = [] + for detected in detected_list: + device_id = detected["device_id"] + if device_id in self._device._children: + result = "added" + successes.append(detected) + else: + result = "not added" + msg = f"{detected['device_model']} - {device_id} - {result}" + _LOGGER.info("Adding child to %s: %s", self._device.host, msg) + + return successes + + async def unpair(self, device_id: str) -> dict: + """Remove device from the hub.""" + _LOGGER.debug("Going to unpair %s from %s", device_id, self) + + payload = {"childControl": {"child_device_list": [{"device_id": device_id}]}} + return await self.call("removeChildDeviceList", payload) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index d096fb5b5..8f46b9764 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from ..device import DeviceInfo from ..device_type import DeviceType @@ -190,10 +190,19 @@ async def _query_setter_helper( return res - async def _query_getter_helper( - self, method: str, module: str, sections: str | list[str] + async def _query_helper( + self, method: str, params: dict | None = None, child_ids: None = None ) -> Any: - res = await self.protocol.query({method: {module: {"name": sections}}}) + if TYPE_CHECKING: + assert params + module = next(iter(params)) + section = params.get(module, {}) + + if isinstance(section, str | list): + section_param = {"name": section} + else: + section_param = section if section is not None else {} + res = await self.protocol.query({method: {module: section_param}}) return res diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py index ef00d47dc..400b16740 100644 --- a/kasa/smartcam/smartcammodule.py +++ b/kasa/smartcam/smartcammodule.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Final from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..modulemapping import ModuleName @@ -68,21 +68,7 @@ async def call(self, method: str, params: dict | None = None) -> dict: Just a helper method. """ - if params: - module = next(iter(params)) - section = next(iter(params[module])) - else: - module = "system" - section = "null" - - if method[:3] == "get": - return await self._device._query_getter_helper(method, module, section) - - if TYPE_CHECKING: - params = cast(dict[str, dict[str, Any]], params) - return await self._device._query_setter_helper( - method, module, section, params[module][section] - ) + return await self._device._query_helper(method, params) @property def data(self) -> dict: diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py index 11a879b4a..5e4396261 100644 --- a/tests/fakeprotocol_smartcam.py +++ b/tests/fakeprotocol_smartcam.py @@ -153,7 +153,33 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): "setup_code": "00000000000", "setup_payload": "00:0000000-0000.00.000", }, - ) + ), + "getSupportChildDeviceCategory": ( + "childQuickSetup", + { + "device_category_list": [ + {"category": "ipcamera"}, + {"category": "subg.trv"}, + {"category": "subg.trigger"}, + {"category": "subg.plugswitch"}, + ] + }, + ), + "getScanChildDeviceList": ( + "childQuickSetup", + { + "child_device_list": [ + { + "device_id": "0000000000000000000000000000000000000000", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw====", + } + ], + "scan_wait_time": 55, + "scan_status": "scanning", + }, + ), } # Setters for when there's not a simple mapping of setters to getters SETTERS = { @@ -179,6 +205,17 @@ def _get_param_set_value(info: dict, set_keys: list[str], value): ], } + def _hub_remove_device(self, info, params): + """Remove hub device.""" + items_to_remove = [dev["device_id"] for dev in params["child_device_list"]] + children = info["getChildDeviceList"]["child_device_list"] + new_children = [ + dev for dev in children if dev["device_id"] not in items_to_remove + ] + info["getChildDeviceList"]["child_device_list"] = new_children + + return {"result": {}, "error_code": 0} + @staticmethod def _get_second_key(request_dict: dict[str, Any]) -> str: assert ( @@ -269,6 +306,14 @@ async def _send_request(self, request_dict: dict): return {**result, "error_code": 0} else: return {"error_code": -1} + elif method == "removeChildDeviceList": + return self._hub_remove_device(info, request_dict["params"]["childControl"]) + # actions + elif method in [ + "addScanChildDeviceList", + "startScanChildDevice", + ]: + return {"result": {}, "error_code": 0} # smartcam child devices do not make requests for getDeviceInfo as they # get updated from the parent's query. If this is being called from a diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py new file mode 100644 index 000000000..07724c3c3 --- /dev/null +++ b/tests/smartcam/modules/test_childsetup.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa import Feature, Module, SmartDevice + +from ...device_fixtures import parametrize + +childsetup = parametrize( + "supports pairing", component_filter="childQuickSetup", protocol_filter={"SMARTCAM"} +) + + +@childsetup +async def test_childsetup_features(dev: SmartDevice): + """Test the exposed features.""" + cs = dev.modules.get(Module.ChildSetup) + assert cs + + assert "pair" in cs._module_features + pair = cs._module_features["pair"] + assert pair.type == Feature.Type.Action + + +@childsetup +async def test_childsetup_pair( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test device pairing.""" + caplog.set_level(logging.INFO) + mock_query_helper = mocker.spy(dev, "_query_helper") + mocker.patch("asyncio.sleep") + + cs = dev.modules.get(Module.ChildSetup) + assert cs + + await cs.pair() + + mock_query_helper.assert_has_awaits( + [ + mocker.call( + "startScanChildDevice", + params={ + "childControl": { + "category": [ + "camera", + "subg.trv", + "subg.trigger", + "subg.plugswitch", + ] + } + }, + ), + mocker.call( + "getScanChildDeviceList", + { + "childControl": { + "category": [ + "camera", + "subg.trv", + "subg.trigger", + "subg.plugswitch", + ] + } + }, + ), + mocker.call( + "addScanChildDeviceList", + { + "childControl": { + "child_device_list": [ + { + "device_id": "0000000000000000000000000000000000000000", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw====", + } + ] + } + }, + ), + ] + ) + assert "Discovery done" in caplog.text + + +@childsetup +async def test_childsetup_unpair( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test unpair.""" + mock_query_helper = mocker.spy(dev, "_query_helper") + DUMMY_ID = "dummy_id" + + cs = dev.modules.get(Module.ChildSetup) + assert cs + + await cs.unpair(DUMMY_ID) + + mock_query_helper.assert_awaited_with( + "removeChildDeviceList", + params={"childControl": {"child_device_list": [{"device_id": DUMMY_ID}]}}, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2f9075028..b57fa7c3e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -267,7 +267,11 @@ async def test_raw_command(dev, mocker, runner): from kasa.smart import SmartDevice if isinstance(dev, SmartCamDevice): - params = ["na", "getDeviceInfo"] + params = [ + "na", + "getDeviceInfo", + '{"device_info": {"name": ["basic_info", "info"]}}', + ] elif isinstance(dev, SmartDevice): params = ["na", "get_device_info"] else: diff --git a/tests/test_feature.py b/tests/test_feature.py index 33a07106c..3ccabeb46 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -191,12 +191,12 @@ async def _test_features(dev): exceptions = [] for feat in dev.features.values(): try: - prot = ( - feat.container._device.protocol - if feat.container - else feat.device.protocol - ) - with patch.object(prot, "query", name=feat.id) as query: + patch_dev = feat.container._device if feat.container else feat.device + with ( + patch.object(patch_dev.protocol, "query", name=feat.id) as query, + # patch update in case feature setter does an update + patch.object(patch_dev, "update"), + ): await _test_feature(feat, query) # we allow our own exceptions to avoid mocking valid responses except KasaException: From 8e2edf926f0ab57bac267bce5079b5892058b04e Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 22 Jan 2025 11:10:21 +0000 Subject: [PATCH 2/6] Apply suggestions from code review --- kasa/smart/modules/childsetup.py | 7 +++++-- kasa/smart/smartdevice.py | 8 ++------ kasa/smartcam/modules/childsetup.py | 17 ++++++++++++----- kasa/smartcam/smartcamdevice.py | 18 +----------------- 4 files changed, 20 insertions(+), 30 deletions(-) diff --git a/kasa/smart/modules/childsetup.py b/kasa/smart/modules/childsetup.py index 04444e2e9..b1a171021 100644 --- a/kasa/smart/modules/childsetup.py +++ b/kasa/smart/modules/childsetup.py @@ -48,7 +48,10 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: detected = await self._get_detected_devices() if not detected["child_device_list"]: - _LOGGER.info("No devices found.") + _LOGGER.warning( + "No devices found, make sure to activate pairing " + "mode on the devices to be added." + ) return [] _LOGGER.info( @@ -63,7 +66,7 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: async def unpair(self, device_id: str) -> dict: """Remove device from the hub.""" - _LOGGER.debug("Going to unpair %s from %s", device_id, self) + _LOGGER.info("Going to unpair %s from %s", device_id, self) payload = {"child_device_list": [{"device_id": device_id}]} return await self.call("remove_child_device_list", payload) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index ee86b0e2a..16ba5ce99 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -691,12 +691,8 @@ def _update_internal_state(self, info: dict[str, Any]) -> None: """ self._info = info - async def _query_helper( - self, method: str, params: dict | None = None, child_ids: None = None - ) -> dict: - res = await self.protocol.query({method: params}) - - return res + async def _query_helper(self, method: str, params: dict | None = None) -> dict: + return await self.protocol.query({method: params}) @property def ssid(self) -> str: diff --git a/kasa/smartcam/modules/childsetup.py b/kasa/smartcam/modules/childsetup.py index 4e768762c..036ee1461 100644 --- a/kasa/smartcam/modules/childsetup.py +++ b/kasa/smartcam/modules/childsetup.py @@ -43,6 +43,11 @@ async def _post_update_hook(self) -> None: for cat in self.data["device_category_list"] ] + @property + def supported_child_device_categories(self) -> list[str]: + """Supported child device categories.""" + return self._categories + async def pair(self, *, timeout: int = 10) -> list[dict]: """Scan for new devices and pair after discovering first new device.""" await self.call( @@ -58,11 +63,10 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: detected_list = res["getScanChildDeviceList"]["child_device_list"] if not detected_list: - detected_list = await self.call( - "getScanChildDeviceList", - {"childControl": {"category": self._categories}}, + _LOGGER.warning( + "No devices found, make sure to activate pairing " + "mode on the devices to be added." ) - _LOGGER.info("No devices found.") return [] _LOGGER.info( @@ -70,7 +74,10 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: len(detected_list), detected_list, ) + return await self._add_devices(detected_list) + async def _add_devices(self, detected_list: list[dict]) -> list: + """Add devices based on getScanChildDeviceList response.""" await self.call( "addScanChildDeviceList", {"childControl": {"child_device_list": detected_list}}, @@ -93,7 +100,7 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: async def unpair(self, device_id: str) -> dict: """Remove device from the hub.""" - _LOGGER.debug("Going to unpair %s from %s", device_id, self) + _LOGGER.info("Going to unpair %s from %s", device_id, self) payload = {"childControl": {"child_device_list": [{"device_id": device_id}]}} return await self.call("removeChildDeviceList", payload) diff --git a/kasa/smartcam/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py index 8f46b9764..676a77724 100644 --- a/kasa/smartcam/smartcamdevice.py +++ b/kasa/smartcam/smartcamdevice.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from ..device import DeviceInfo from ..device_type import DeviceType @@ -190,22 +190,6 @@ async def _query_setter_helper( return res - async def _query_helper( - self, method: str, params: dict | None = None, child_ids: None = None - ) -> Any: - if TYPE_CHECKING: - assert params - module = next(iter(params)) - section = params.get(module, {}) - - if isinstance(section, str | list): - section_param = {"name": section} - else: - section_param = section if section is not None else {} - res = await self.protocol.query({method: {module: section_param}}) - - return res - @staticmethod def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]: return { From 88f64d2f1a14fe9d3dd78af3d5ce9ec4444e3a71 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:36:49 +0000 Subject: [PATCH 3/6] Add common childsetup interface --- docs/source/guides/strip.md | 7 +++ docs/tutorial.py | 1 + kasa/cli/hub.py | 3 +- kasa/discover.py | 11 ++-- kasa/interfaces/__init__.py | 2 + kasa/interfaces/childsetup.py | 70 +++++++++++++++++++++++ kasa/module.py | 2 +- kasa/smart/modules/childsetup.py | 48 +++++++++++----- kasa/smartcam/modules/childsetup.py | 23 +++++--- tests/cli/test_hub.py | 6 +- tests/device_fixtures.py | 1 + tests/discovery_fixtures.py | 1 + tests/fakeprotocol_smart.py | 13 ++++- tests/smart/modules/test_childsetup.py | 1 - tests/smartcam/modules/test_childsetup.py | 28 ++------- tests/test_readme_examples.py | 23 ++++++++ 16 files changed, 182 insertions(+), 58 deletions(-) create mode 100644 kasa/interfaces/childsetup.py diff --git a/docs/source/guides/strip.md b/docs/source/guides/strip.md index d1377eab8..b6e914cc4 100644 --- a/docs/source/guides/strip.md +++ b/docs/source/guides/strip.md @@ -8,3 +8,10 @@ .. automodule:: kasa.smart.modules.childdevice :noindex: ``` + +## Pairing and unpairing + +```{eval-rst} +.. automodule:: kasa.interfaces.childsetup + :noindex: +``` diff --git a/docs/tutorial.py b/docs/tutorial.py index fddcc79a6..1f27ddc17 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -13,6 +13,7 @@ 127.0.0.3 127.0.0.4 127.0.0.5 +127.0.0.6 :meth:`~kasa.Discover.discover_single` returns a single device by hostname: diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py index 444781326..11a71eb0c 100644 --- a/kasa/cli/hub.py +++ b/kasa/cli/hub.py @@ -44,8 +44,7 @@ async def hub_supported(dev: SmartDevice): """List supported hub child device categories.""" cs = dev.modules[Module.ChildSetup] - cats = [cat["category"] for cat in await cs.get_supported_device_categories()] - for cat in cats: + for cat in cs.supported_child_device_categories: echo(f"Supports: {cat}") diff --git a/kasa/discover.py b/kasa/discover.py index abcd7d5fa..80b273a80 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -22,7 +22,7 @@ >>> >>> found_devices = await Discover.discover() >>> [dev.model for dev in found_devices.values()] -['KP303', 'HS110', 'L530E', 'KL430', 'HS220'] +['KP303', 'HS110', 'L530E', 'KL430', 'HS220', 'H200'] You can pass username and password for devices requiring authentication @@ -31,21 +31,21 @@ >>> password="great_password", >>> ) >>> print(len(devices)) -5 +6 You can also pass a :class:`kasa.Credentials` >>> creds = Credentials("user@example.com", "great_password") >>> devices = await Discover.discover(credentials=creds) >>> print(len(devices)) -5 +6 Discovery can also be targeted to a specific broadcast address instead of the default 255.255.255.255: >>> found_devices = await Discover.discover(target="127.0.0.255", credentials=creds) >>> print(len(found_devices)) -5 +6 Basic information is available on the device from the discovery broadcast response but it is important to call device.update() after discovery if you want to access @@ -65,11 +65,12 @@ >>> print(f"Discovered {dev.alias} (model: {dev.model})") >>> >>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) +Discovered Living Room Bulb (model: L530) Discovered Bedroom Power Strip (model: KP303) Discovered Bedroom Lamp Plug (model: HS110) -Discovered Living Room Bulb (model: L530) Discovered Bedroom Lightstrip (model: KL430) Discovered Living Room Dimmer Switch (model: HS220) +Discovered Tapo Hub (model: H200) Discovering a single device returns a kasa.Device object. diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index e5fd4caee..fc82ee0bc 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,5 +1,6 @@ """Package for interfaces.""" +from .childsetup import ChildSetup from .energy import Energy from .fan import Fan from .led import Led @@ -10,6 +11,7 @@ from .time import Time __all__ = [ + "ChildSetup", "Fan", "Energy", "Led", diff --git a/kasa/interfaces/childsetup.py b/kasa/interfaces/childsetup.py new file mode 100644 index 000000000..452f45e39 --- /dev/null +++ b/kasa/interfaces/childsetup.py @@ -0,0 +1,70 @@ +"""Module for childsetup interface. + +The childsetup module allows pairing and unpairing of supported child device types to +hubs. + +>>> from kasa import Discover, Module, LightState +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.6", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Tapo Hub + +>>> childsetup = dev.modules[Module.ChildSetup] +>>> childsetup.supported_child_device_categories +['camera', 'subg.trv', 'subg.trigger', 'subg.plugswitch'] + +Put child devices in pairing mode. +The hub will pair with all supported devices in pairing mode + +>>> added = await childsetup.pair() +>>> added +[{'device_id': 'SCRUBBED_CHILD_DEVICE_ID_5', 'category': 'subg.trigger.button', \ +'device_model': 'S200B', 'name': 'I01BU0tFRF9OQU1FIw===='}] + +>>> for child in dev.children: +>>> print(f"{child.device_id} - {child.model}") +SCRUBBED_CHILD_DEVICE_ID_1 - T310 +SCRUBBED_CHILD_DEVICE_ID_2 - T315 +SCRUBBED_CHILD_DEVICE_ID_3 - T110 +SCRUBBED_CHILD_DEVICE_ID_4 - S200B +SCRUBBED_CHILD_DEVICE_ID_5 - S200B + +Unpair with the child device id + +>>> await childsetup.unpair("SCRUBBED_CHILD_DEVICE_ID_4") +>>> for child in dev.children: +>>> print(f"{child.device_id} - {child.model}") +SCRUBBED_CHILD_DEVICE_ID_1 - T310 +SCRUBBED_CHILD_DEVICE_ID_2 - T315 +SCRUBBED_CHILD_DEVICE_ID_3 - T110 +SCRUBBED_CHILD_DEVICE_ID_5 - S200B + +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..module import Module + + +class ChildSetup(Module, ABC): + """Interface for child setup on hubs.""" + + @property + @abstractmethod + def supported_child_device_categories(self) -> list[str]: + """Supported child device categories.""" + + @abstractmethod + async def pair(self, *, timeout: int = 10) -> list[dict]: + """Scan for new devices and pair after discovering first new device.""" + + @abstractmethod + async def unpair(self, device_id: str) -> dict: + """Remove device from the hub.""" diff --git a/kasa/module.py b/kasa/module.py index 6f188b305..107ce1e60 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -93,6 +93,7 @@ class Module(ABC): """ # Common Modules + ChildSetup: Final[ModuleName[interfaces.ChildSetup]] = ModuleName("ChildSetup") Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy") Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") @@ -154,7 +155,6 @@ class Module(ABC): ) ChildLock: Final[ModuleName[smart.ChildLock]] = ModuleName("ChildLock") TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") - ChildSetup: Final[ModuleName[smart.ChildSetup]] = ModuleName("ChildSetup") HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit") Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter") diff --git a/kasa/smart/modules/childsetup.py b/kasa/smart/modules/childsetup.py index b1a171021..0de9a9bad 100644 --- a/kasa/smart/modules/childsetup.py +++ b/kasa/smart/modules/childsetup.py @@ -9,16 +9,21 @@ import logging from ...feature import Feature +from ...interfaces.childsetup import ChildSetup as ChildSetupInterface from ..smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) -class ChildSetup(SmartModule): +class ChildSetup(SmartModule, ChildSetupInterface): """Implementation for child device setup.""" REQUIRED_COMPONENT = "child_quick_setup" QUERY_GETTER_NAME = "get_support_child_device_category" + _categories: list[str] = [] + + # Supported child device categories will hardly ever change + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 def _initialize_features(self) -> None: """Initialize features.""" @@ -34,10 +39,15 @@ def _initialize_features(self) -> None: ) ) - async def get_supported_device_categories(self) -> list[dict]: - """Get supported device categories.""" - categories = await self.call("get_support_child_device_category") - return categories["get_support_child_device_category"]["device_category_list"] + async def _post_update_hook(self) -> None: + self._categories = [ + cat["category"] for cat in self.data["device_category_list"] + ] + + @property + def supported_child_device_categories(self) -> list[str]: + """Supported child device categories.""" + return self._categories async def pair(self, *, timeout: int = 10) -> list[dict]: """Scan for new devices and pair after discovering first new device.""" @@ -60,28 +70,40 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: detected, ) - await self._add_devices(detected) - - return detected["child_device_list"] + return await self._add_devices(detected) async def unpair(self, device_id: str) -> dict: """Remove device from the hub.""" _LOGGER.info("Going to unpair %s from %s", device_id, self) payload = {"child_device_list": [{"device_id": device_id}]} - return await self.call("remove_child_device_list", payload) + res = await self.call("remove_child_device_list", payload) + await self._device.update() + return res - async def _add_devices(self, devices: dict) -> dict: + async def _add_devices(self, devices: dict) -> list[dict]: """Add devices based on get_detected_device response. Pass the output from :ref:_get_detected_devices: as a parameter. """ - res = await self.call("add_child_device_list", devices) - return res + await self.call("add_child_device_list", devices) + + successes = [] + for detected in devices["child_device_list"]: + device_id = detected["device_id"] + if device_id in self._device._children: + result = "added" + successes.append(detected) + else: + result = "not added" + msg = f"{detected['device_model']} - {device_id} - {result}" + _LOGGER.info("Added child to %s: %s", self._device.host, msg) + + return successes async def _get_detected_devices(self) -> dict: """Return list of devices detected during scanning.""" - param = {"scan_list": await self.get_supported_device_categories()} + param = {"scan_list": self.data["device_category_list"]} res = await self.call("get_scan_child_device_list", param) _LOGGER.debug("Scan status: %s", res) return res["get_scan_child_device_list"] diff --git a/kasa/smartcam/modules/childsetup.py b/kasa/smartcam/modules/childsetup.py index 036ee1461..f0520f80b 100644 --- a/kasa/smartcam/modules/childsetup.py +++ b/kasa/smartcam/modules/childsetup.py @@ -9,12 +9,13 @@ import logging from ...feature import Feature +from ...interfaces.childsetup import ChildSetup as ChildSetupInterface from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) -class ChildSetup(SmartCamModule): +class ChildSetup(SmartCamModule, ChildSetupInterface): """Implementation for child device setup.""" REQUIRED_COMPONENT = "childQuickSetup" @@ -22,6 +23,9 @@ class ChildSetup(SmartCamModule): QUERY_MODULE_NAME = "childControl" _categories: list[str] = [] + # Supported child device categories will hardly ever change + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 + def _initialize_features(self) -> None: """Initialize features.""" self._add_feature( @@ -37,11 +41,10 @@ def _initialize_features(self) -> None: ) async def _post_update_hook(self) -> None: - if not self._categories: - self._categories = [ - cat["category"].replace("ipcamera", "camera") - for cat in self.data["device_category_list"] - ] + self._categories = [ + cat["category"].replace("ipcamera", "camera") + for cat in self.data["device_category_list"] + ] @property def supported_child_device_categories(self) -> list[str]: @@ -76,7 +79,7 @@ async def pair(self, *, timeout: int = 10) -> list[dict]: ) return await self._add_devices(detected_list) - async def _add_devices(self, detected_list: list[dict]) -> list: + async def _add_devices(self, detected_list: list[dict]) -> list[dict]: """Add devices based on getScanChildDeviceList response.""" await self.call( "addScanChildDeviceList", @@ -94,7 +97,7 @@ async def _add_devices(self, detected_list: list[dict]) -> list: else: result = "not added" msg = f"{detected['device_model']} - {device_id} - {result}" - _LOGGER.info("Adding child to %s: %s", self._device.host, msg) + _LOGGER.info("Added child to %s: %s", self._device.host, msg) return successes @@ -103,4 +106,6 @@ async def unpair(self, device_id: str) -> dict: _LOGGER.info("Going to unpair %s from %s", device_id, self) payload = {"childControl": {"child_device_list": [{"device_id": device_id}]}} - return await self.call("removeChildDeviceList", payload) + res = await self.call("removeChildDeviceList", payload) + await self._device.update() + return res diff --git a/tests/cli/test_hub.py b/tests/cli/test_hub.py index 5236f4cda..00c3645ed 100644 --- a/tests/cli/test_hub.py +++ b/tests/cli/test_hub.py @@ -4,10 +4,10 @@ from kasa import DeviceType, Module from kasa.cli.hub import hub -from ..device_fixtures import HUBS_SMART, hubs_smart, parametrize, plug_iot +from ..device_fixtures import hubs, plug_iot -@hubs_smart +@hubs async def test_hub_pair(dev, mocker: MockerFixture, runner, caplog): """Test that pair calls the expected methods.""" cs = dev.modules.get(Module.ChildSetup) @@ -25,7 +25,7 @@ async def test_hub_pair(dev, mocker: MockerFixture, runner, caplog): assert res.exit_code == 0 -@parametrize("hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"}) +@hubs async def test_hub_unpair(dev, mocker: MockerFixture, runner): """Test that unpair calls the expected method.""" if not dev.children: diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index f28b17e3d..9cafb185d 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -345,6 +345,7 @@ def parametrize( device_type_filter=[DeviceType.Hub], protocol_filter={"SMARTCAM"}, ) +hubs = parametrize_combine([hubs_smart, hub_smartcam]) vacuum = parametrize("vacuums", device_type_filter=[DeviceType.Vacuum]) diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index eb843f1a0..22832391c 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -295,6 +295,7 @@ def _getaddrinfo(host, *_, **__): # side_effect=lambda *_, **__: [(None, None, None, None, (first_ip, 0))], side_effect=_getaddrinfo, ) + mocker.patch("kasa.discover.Discover._decrypt_discovery_data") # Only return the first discovery mock to be used for testing discover single return discovery_mocks[first_ip] diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py index ba47f0d55..d2367d9fa 100644 --- a/tests/fakeprotocol_smart.py +++ b/tests/fakeprotocol_smart.py @@ -176,10 +176,19 @@ def credentials_hash(self): "child_quick_setup", {"device_category_list": [{"category": "subg.trv"}]}, ), - # no devices found "get_scan_child_device_list": ( "child_quick_setup", - {"child_device_list": [{"dummy": "response"}], "scan_status": "idle"}, + { + "child_device_list": [ + { + "device_id": "0000000000000000000000000000000000000000", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw==", + } + ], + "scan_status": "idle", + }, ), } diff --git a/tests/smart/modules/test_childsetup.py b/tests/smart/modules/test_childsetup.py index df3905a64..6f31a9488 100644 --- a/tests/smart/modules/test_childsetup.py +++ b/tests/smart/modules/test_childsetup.py @@ -42,7 +42,6 @@ async def test_childsetup_pair( mock_query_helper.assert_has_awaits( [ mocker.call("begin_scanning_child_device", None), - mocker.call("get_support_child_device_category", None), mocker.call("get_scan_child_device_list", params=mocker.ANY), mocker.call("add_child_device_list", params=mocker.ANY), ] diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py index 07724c3c3..0996b2bc9 100644 --- a/tests/smartcam/modules/test_childsetup.py +++ b/tests/smartcam/modules/test_childsetup.py @@ -44,28 +44,12 @@ async def test_childsetup_pair( mocker.call( "startScanChildDevice", params={ - "childControl": { - "category": [ - "camera", - "subg.trv", - "subg.trigger", - "subg.plugswitch", - ] - } + "childControl": {"category": cs.supported_child_device_categories} }, ), mocker.call( "getScanChildDeviceList", - { - "childControl": { - "category": [ - "camera", - "subg.trv", - "subg.trigger", - "subg.plugswitch", - ] - } - }, + {"childControl": {"category": cs.supported_child_device_categories}}, ), mocker.call( "addScanChildDeviceList", @@ -73,10 +57,10 @@ async def test_childsetup_pair( "childControl": { "child_device_list": [ { - "device_id": "0000000000000000000000000000000000000000", - "category": "subg.trigger.button", - "device_model": "S200B", - "name": "I01BU0tFRF9OQU1FIw====", + "device_id": mocker.ANY, + "category": mocker.ANY, + "device_model": mocker.ANY, + "name": mocker.ANY, } ] } diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index b6513476f..2431127c7 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -148,6 +148,25 @@ def test_tutorial_examples(readmes_mock): assert not res["failed"] +def test_childsetup_examples(readmes_mock, mocker): + """Test device examples.""" + pair_resp = [ + { + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "category": "subg.trigger.button", + "device_model": "S200B", + "name": "I01BU0tFRF9OQU1FIw====", + } + ] + mocker.patch( + "kasa.smartcam.modules.childsetup.ChildSetup.pair", return_value=pair_resp + ) + res = xdoctest.doctest_module("kasa.interfaces.childsetup", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + @pytest.fixture async def readmes_mock(mocker): fixture_infos = { @@ -156,6 +175,7 @@ async def readmes_mock(mocker): "127.0.0.3": get_fixture_info("L530E(EU)_3.0_1.1.6.json", "SMART"), # Bulb "127.0.0.4": get_fixture_info("KL430(US)_1.0_1.0.10.json", "IOT"), # Lightstrip "127.0.0.5": get_fixture_info("HS220(US)_1.0_1.5.7.json", "IOT"), # Dimmer + "127.0.0.6": get_fixture_info("H200(US)_1.0_1.3.6.json", "SMARTCAM"), # Hub } fixture_infos["127.0.0.1"].data["system"]["get_sysinfo"]["alias"] = ( "Bedroom Power Strip" @@ -176,4 +196,7 @@ async def readmes_mock(mocker): fixture_infos["127.0.0.5"].data["system"]["get_sysinfo"]["alias"] = ( "Living Room Dimmer Switch" ) + fixture_infos["127.0.0.6"].data["getDeviceInfo"]["device_info"]["basic_info"][ + "device_alias" + ] = "Tapo Hub" return patch_discovery(fixture_infos, mocker) From c11da2e56081c5b707b42e31f7b60c87837fa19d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 22 Jan 2025 15:44:45 +0000 Subject: [PATCH 4/6] Fix discovery readme example --- kasa/discover.py | 2 +- tests/discovery_fixtures.py | 56 +++++++++++++++++++++++++++++++------ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 80b273a80..699829738 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -65,9 +65,9 @@ >>> print(f"Discovered {dev.alias} (model: {dev.model})") >>> >>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) -Discovered Living Room Bulb (model: L530) Discovered Bedroom Power Strip (model: KP303) Discovered Bedroom Lamp Plug (model: HS110) +Discovered Living Room Bulb (model: L530) Discovered Bedroom Lightstrip (model: KL430) Discovered Living Room Dimmer Switch (model: HS220) Discovered Tapo Hub (model: H200) diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 22832391c..fc6ead885 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -1,6 +1,8 @@ from __future__ import annotations +import asyncio import copy +from collections.abc import Coroutine from dataclasses import dataclass from json import dumps as json_dumps from typing import Any, TypedDict @@ -243,12 +245,46 @@ def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): first_ip = list(fixture_infos.keys())[0] first_host = None + # Mock _run_callback_task so the tasks complete in the order they started. + # Otherwise test output is non-deterministic which affects readme examples. + callback_queue: asyncio.Queue = asyncio.Queue() + exception_queue: asyncio.Queue = asyncio.Queue() + + async def process_callback_queue(finished_event: asyncio.Event) -> None: + while (finished_event.is_set() is False) or callback_queue.qsize(): + coro = await callback_queue.get() + try: + await coro + except Exception as ex: + await exception_queue.put(ex) + else: + await exception_queue.put(None) + callback_queue.task_done() + + async def wait_for_coro(): + await callback_queue.join() + if ex := exception_queue.get_nowait(): + raise ex + + def _run_callback_task(self, coro: Coroutine) -> None: + callback_queue.put_nowait(coro) + task = asyncio.create_task(wait_for_coro()) + self.callback_tasks.append(task) + + mocker.patch( + "kasa.discover._DiscoverProtocol._run_callback_task", _run_callback_task + ) + + # do_discover_mock async def mock_discover(self): """Call datagram_received for all mock fixtures. Handles test cases modifying the ip and hostname of the first fixture for discover_single testing. """ + finished_event = asyncio.Event() + asyncio.create_task(process_callback_queue(finished_event)) + for ip, dm in discovery_mocks.items(): first_ip = list(discovery_mocks.values())[0].ip fixture_info = fixture_infos[ip] @@ -275,10 +311,18 @@ async def mock_discover(self): dm._datagram, (dm.ip, port), ) + # Setting this event will stop the processing of callbacks + finished_event.set() + + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + # query_mock async def _query(self, request, retry_count: int = 3): return await protos[self._host].query(request) + mocker.patch("kasa.IotProtocol.query", _query) + mocker.patch("kasa.SmartProtocol.query", _query) + def _getaddrinfo(host, *_, **__): nonlocal first_host, first_ip first_host = host # Store the hostname used by discover single @@ -287,15 +331,11 @@ def _getaddrinfo(host, *_, **__): ].ip # ip could have been overridden in test return [(None, None, None, None, (first_ip, 0))] - mocker.patch("kasa.IotProtocol.query", _query) - mocker.patch("kasa.SmartProtocol.query", _query) - mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) - mocker.patch( - "socket.getaddrinfo", - # side_effect=lambda *_, **__: [(None, None, None, None, (first_ip, 0))], - side_effect=_getaddrinfo, - ) + mocker.patch("socket.getaddrinfo", side_effect=_getaddrinfo) + + # Mock decrypt so it doesn't error with unencryptable empty data in the fixtures mocker.patch("kasa.discover.Discover._decrypt_discovery_data") + # Only return the first discovery mock to be used for testing discover single return discovery_mocks[first_ip] From 8b842ceb1cfb1dd7cee5daf3ae217ce27e10a7e4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:53:23 +0000 Subject: [PATCH 5/6] Apply suggestions from code review Co-authored-by: Teemu R. --- kasa/interfaces/childsetup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kasa/interfaces/childsetup.py b/kasa/interfaces/childsetup.py index 452f45e39..11cbb96d6 100644 --- a/kasa/interfaces/childsetup.py +++ b/kasa/interfaces/childsetup.py @@ -19,7 +19,7 @@ ['camera', 'subg.trv', 'subg.trigger', 'subg.plugswitch'] Put child devices in pairing mode. -The hub will pair with all supported devices in pairing mode +The hub will pair with all supported devices in pairing mode: >>> added = await childsetup.pair() >>> added @@ -34,7 +34,7 @@ SCRUBBED_CHILD_DEVICE_ID_4 - S200B SCRUBBED_CHILD_DEVICE_ID_5 - S200B -Unpair with the child device id +Unpair with the child `device_id`: >>> await childsetup.unpair("SCRUBBED_CHILD_DEVICE_ID_4") >>> for child in dev.children: @@ -63,7 +63,7 @@ def supported_child_device_categories(self) -> list[str]: @abstractmethod async def pair(self, *, timeout: int = 10) -> list[dict]: - """Scan for new devices and pair after discovering first new device.""" + """Scan for new devices and pair them.""" @abstractmethod async def unpair(self, device_id: str) -> dict: From 7821cb4bbc86a9afcc464e9c92bf1fb662e1102a Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:11:31 +0000 Subject: [PATCH 6/6] Apply more suggestions from code review --- kasa/cli/hub.py | 2 +- kasa/interfaces/childsetup.py | 4 ++-- kasa/smart/modules/childsetup.py | 11 +++++++---- kasa/smartcam/modules/childsetup.py | 4 ++-- tests/smartcam/modules/test_childsetup.py | 6 ++---- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py index 11a71eb0c..3add28149 100644 --- a/kasa/cli/hub.py +++ b/kasa/cli/hub.py @@ -44,7 +44,7 @@ async def hub_supported(dev: SmartDevice): """List supported hub child device categories.""" cs = dev.modules[Module.ChildSetup] - for cat in cs.supported_child_device_categories: + for cat in cs.supported_categories: echo(f"Supports: {cat}") diff --git a/kasa/interfaces/childsetup.py b/kasa/interfaces/childsetup.py index 11cbb96d6..f91a8383c 100644 --- a/kasa/interfaces/childsetup.py +++ b/kasa/interfaces/childsetup.py @@ -15,7 +15,7 @@ Tapo Hub >>> childsetup = dev.modules[Module.ChildSetup] ->>> childsetup.supported_child_device_categories +>>> childsetup.supported_categories ['camera', 'subg.trv', 'subg.trigger', 'subg.plugswitch'] Put child devices in pairing mode. @@ -58,7 +58,7 @@ class ChildSetup(Module, ABC): @property @abstractmethod - def supported_child_device_categories(self) -> list[str]: + def supported_categories(self) -> list[str]: """Supported child device categories.""" @abstractmethod diff --git a/kasa/smart/modules/childsetup.py b/kasa/smart/modules/childsetup.py index 0de9a9bad..f3bf88c8d 100644 --- a/kasa/smart/modules/childsetup.py +++ b/kasa/smart/modules/childsetup.py @@ -45,12 +45,12 @@ async def _post_update_hook(self) -> None: ] @property - def supported_child_device_categories(self) -> list[str]: + def supported_categories(self) -> list[str]: """Supported child device categories.""" return self._categories async def pair(self, *, timeout: int = 10) -> list[dict]: - """Scan for new devices and pair after discovering first new device.""" + """Scan for new devices and pair them.""" await self.call("begin_scanning_child_device") _LOGGER.info("Waiting %s seconds for discovering new devices", timeout) @@ -88,14 +88,17 @@ async def _add_devices(self, devices: dict) -> list[dict]: """ await self.call("add_child_device_list", devices) + await self._device.update() + successes = [] for detected in devices["child_device_list"]: device_id = detected["device_id"] + + result = "not added" if device_id in self._device._children: result = "added" successes.append(detected) - else: - result = "not added" + msg = f"{detected['device_model']} - {device_id} - {result}" _LOGGER.info("Added child to %s: %s", self._device.host, msg) diff --git a/kasa/smartcam/modules/childsetup.py b/kasa/smartcam/modules/childsetup.py index 8f20c817c..676bd6368 100644 --- a/kasa/smartcam/modules/childsetup.py +++ b/kasa/smartcam/modules/childsetup.py @@ -47,12 +47,12 @@ async def _post_update_hook(self) -> None: ] @property - def supported_child_device_categories(self) -> list[str]: + def supported_categories(self) -> list[str]: """Supported child device categories.""" return self._categories async def pair(self, *, timeout: int = 10) -> list[dict]: - """Scan for new devices and pair after discovering first new device.""" + """Scan for new devices and pair them.""" await self.call( "startScanChildDevice", {"childControl": {"category": self._categories}} ) diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py index 7e732940b..5b8a7c494 100644 --- a/tests/smartcam/modules/test_childsetup.py +++ b/tests/smartcam/modules/test_childsetup.py @@ -41,13 +41,11 @@ async def test_childsetup_pair( [ mocker.call( "startScanChildDevice", - params={ - "childControl": {"category": cs.supported_child_device_categories} - }, + params={"childControl": {"category": cs.supported_categories}}, ), mocker.call( "getScanChildDeviceList", - {"childControl": {"category": cs.supported_child_device_categories}}, + {"childControl": {"category": cs.supported_categories}}, ), mocker.call( "addScanChildDeviceList",