Add childsetup module to smartcam hubs (#1469)

Add the `childsetup` module for `smartcam` hubs to allow pairing and unpairing child devices.
This commit is contained in:
Steven B.
2025-01-23 09:42:37 +00:00
committed by GitHub
parent bd43e0f7d2
commit 5e57f8bd6c
10 changed files with 278 additions and 39 deletions

View File

@@ -153,7 +153,33 @@ class FakeSmartCamTransport(BaseTransport):
"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 @@ class FakeSmartCamTransport(BaseTransport):
],
}
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 @@ class FakeSmartCamTransport(BaseTransport):
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

View File

@@ -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}]}},
)

View File

@@ -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:

View File

@@ -191,12 +191,12 @@ async def test_feature_setters(dev: Device, mocker: MockerFixture):
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: