Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions kasa/smart/modules/childsetup.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@
detected = await self._get_detected_devices()

if not detected["child_device_list"]:
_LOGGER.info("No devices found.")
_LOGGER.warning(

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

View check run for this annotation

Codecov / codecov/patch

kasa/smart/modules/childsetup.py#L51

Added line #L51 was not covered by tests
"No devices found, make sure to activate pairing "
"mode on the devices to be added."
)
return []

_LOGGER.info(
Expand All @@ -63,7 +66,7 @@

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)
Expand Down
8 changes: 2 additions & 6 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions kasa/smartcam/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@
"Battery",
"Camera",
"ChildDevice",
"ChildSetup",
"DeviceModule",
"Led",
"PanTilt",
Expand Down
107 changes: 107 additions & 0 deletions kasa/smartcam/modules/childsetup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""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"]
]

@property
def supported_child_device_categories(self) -> list[str]:
"""Supported child device categories."""
return self._categories

Check warning on line 49 in kasa/smartcam/modules/childsetup.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/childsetup.py#L49

Added line #L49 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."""
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:
_LOGGER.warning(

Check warning on line 66 in kasa/smartcam/modules/childsetup.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/childsetup.py#L66

Added line #L66 was not covered by tests
"No devices found, make sure to activate pairing "
"mode on the devices to be added."
)
return []

Check warning on line 70 in kasa/smartcam/modules/childsetup.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/childsetup.py#L70

Added line #L70 was not covered by tests

_LOGGER.info(
"Discovery done, found %s devices: %s",
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}},
)

await self._device.update()

successes = []
for detected in detected_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 95 in kasa/smartcam/modules/childsetup.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/childsetup.py#L94-L95

Added lines #L94 - L95 were not covered by tests

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.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)
7 changes: 0 additions & 7 deletions kasa/smartcam/smartcamdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,6 @@ async def _query_setter_helper(

return res

async def _query_getter_helper(
self, method: str, module: str, sections: str | list[str]
) -> Any:
res = await self.protocol.query({method: {module: {"name": sections}}})

return res

@staticmethod
def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]:
return {
Expand Down
18 changes: 2 additions & 16 deletions kasa/smartcam/smartcammodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
47 changes: 46 additions & 1 deletion tests/fakeprotocol_smartcam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions tests/smartcam/modules/test_childsetup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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[Module.ChildSetup]

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[Module.ChildSetup]

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[Module.ChildSetup]

await cs.unpair(DUMMY_ID)

mock_query_helper.assert_awaited_with(
"removeChildDeviceList",
params={"childControl": {"child_device_list": [{"device_id": DUMMY_ID}]}},
)
6 changes: 5 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading