Skip to content
7 changes: 7 additions & 0 deletions docs/source/guides/strip.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@
.. automodule:: kasa.smart.modules.childdevice
:noindex:
```

## Pairing and unpairing

```{eval-rst}
.. automodule:: kasa.interfaces.childsetup
:noindex:
```
1 change: 1 addition & 0 deletions docs/tutorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 1 addition & 2 deletions kasa/cli/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_categories:
echo(f"Supports: {cat}")


Expand Down
9 changes: 5 additions & 4 deletions kasa/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -70,6 +70,7 @@
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.

Expand Down
2 changes: 2 additions & 0 deletions kasa/interfaces/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Package for interfaces."""

from .childsetup import ChildSetup
from .energy import Energy
from .fan import Fan
from .led import Led
Expand All @@ -10,6 +11,7 @@
from .time import Time

__all__ = [
"ChildSetup",
"Fan",
"Energy",
"Led",
Expand Down
70 changes: 70 additions & 0 deletions kasa/interfaces/childsetup.py
Original file line number Diff line number Diff line change
@@ -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_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_categories(self) -> list[str]:
"""Supported child device categories."""

@abstractmethod
async def pair(self, *, timeout: int = 10) -> list[dict]:
"""Scan for new devices and pair them."""

@abstractmethod
async def unpair(self, device_id: str) -> dict:
"""Remove device from the hub."""
2 changes: 1 addition & 1 deletion kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
53 changes: 39 additions & 14 deletions kasa/smart/modules/childsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -34,13 +39,18 @@
)
)

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_categories(self) -> list[str]:
"""Supported child device categories."""
return self._categories

Check warning on line 50 in kasa/smart/modules/childsetup.py

View check run for this annotation

Codecov / codecov/patch

kasa/smart/modules/childsetup.py#L50

Added line #L50 was not covered by tests

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)
Expand All @@ -60,28 +70,43 @@
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)

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)

Check warning on line 100 in kasa/smart/modules/childsetup.py

View check run for this annotation

Codecov / codecov/patch

kasa/smart/modules/childsetup.py#L99-L100

Added lines #L99 - L100 were not covered by tests

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"]
25 changes: 15 additions & 10 deletions kasa/smartcam/modules/childsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,23 @@
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"
QUERY_GETTER_NAME = "getSupportChildDeviceCategory"
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(
Expand All @@ -37,19 +41,18 @@ 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]:
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}}
)
Expand All @@ -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",
Expand Down Expand Up @@ -104,4 +107,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
6 changes: 3 additions & 3 deletions tests/cli/test_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions tests/device_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ def parametrize(
device_type_filter=[DeviceType.Hub],
protocol_filter={"SMARTCAM"},
)
hubs = parametrize_combine([hubs_smart, hub_smartcam])
doobell_smartcam = parametrize(
"doorbell smartcam",
device_type_filter=[DeviceType.Doorbell],
Expand Down
13 changes: 11 additions & 2 deletions tests/fakeprotocol_smart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
),
}

Expand Down
1 change: 0 additions & 1 deletion tests/smart/modules/test_childsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
Expand Down
Loading
Loading