8000 Add common childsetup interface by sdb9696 · Pull Request #1470 · python-kasa/python-kasa · GitHub
[go: up one dir, main page]

Skip to content

Add common childsetup interface #1470

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jan 24, 2025
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
8000
Original file line numberDiff 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
A3DB
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
0