Add support for pairing devices with hubs (#859)

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
This commit is contained in:
Teemu R.
2025-01-20 11:36:06 +01:00
committed by GitHub
parent 2d26f91981
commit bca5576425
16 changed files with 412 additions and 15 deletions

0
tests/cli/__init__.py Normal file
View File

53
tests/cli/test_hub.py Normal file
View File

@@ -0,0 +1,53 @@
import pytest
from pytest_mock import MockerFixture
from kasa import DeviceType, Module
from kasa.cli.hub import hub
from ..device_fixtures import HUBS_SMART, hubs_smart, parametrize, plug_iot
@hubs_smart
async def test_hub_pair(dev, mocker: MockerFixture, runner, caplog):
"""Test that pair calls the expected methods."""
cs = dev.modules.get(Module.ChildSetup)
# Patch if the device supports the module
if cs is not None:
mock_pair = mocker.patch.object(cs, "pair")
res = await runner.invoke(hub, ["pair"], obj=dev, catch_exceptions=False)
if cs is None:
assert "is not a hub" in res.output
return
mock_pair.assert_awaited()
assert "Finding new devices for 10 seconds" in res.output
assert res.exit_code == 0
@parametrize("hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"})
async def test_hub_unpair(dev, mocker: MockerFixture, runner):
"""Test that unpair calls the expected method."""
if not dev.children:
pytest.skip("Cannot test without child devices")
id_ = next(iter(dev.children)).device_id
cs = dev.modules.get(Module.ChildSetup)
mock_unpair = mocker.spy(cs, "unpair")
res = await runner.invoke(hub, ["unpair", id_], obj=dev, catch_exceptions=False)
mock_unpair.assert_awaited()
assert f"Unpaired {id_}" in res.output
assert res.exit_code == 0
@plug_iot
async def test_non_hub(dev, mocker: MockerFixture, runner):
"""Test that hub commands return an error if executed on a non-hub."""
assert dev.device_type is not DeviceType.Hub
res = await runner.invoke(
hub, ["unpair", "dummy_id"], obj=dev, catch_exceptions=False
)
assert "is not a hub" in res.output

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import os
import sys
import warnings
from pathlib import Path
@@ -8,6 +9,9 @@ from unittest.mock import MagicMock, patch
import pytest
# TODO: this and runner fixture could be moved to tests/cli/conftest.py
from asyncclick.testing import CliRunner
from kasa import (
DeviceConfig,
SmartProtocol,
@@ -149,3 +153,12 @@ def mock_datagram_endpoint(request): # noqa: PT004
side_effect=_create_datagram_endpoint,
):
yield
@pytest.fixture
def runner():
"""Runner fixture that unsets the KASA_ environment variables for tests."""
KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")}
runner = CliRunner(env=KASA_VARS)
return runner

View File

@@ -171,6 +171,16 @@ class FakeSmartTransport(BaseTransport):
"setup_payload": "00:0000000-0000.00.000",
},
),
# child setup
"get_support_child_device_category": (
"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"},
),
}
def _missing_result(self, method):
@@ -548,6 +558,17 @@ class FakeSmartTransport(BaseTransport):
return {"error_code": 0}
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["get_child_device_list"]["child_device_list"]
new_children = [
dev for dev in children if dev["device_id"] not in items_to_remove
]
info["get_child_device_list"]["child_device_list"] = new_children
return {"error_code": 0}
def get_child_device_queries(self, method, params):
return self._get_method_from_info(method, params)
@@ -658,8 +679,15 @@ class FakeSmartTransport(BaseTransport):
return self._set_on_off_gradually_info(info, params)
elif method == "set_child_protection":
return self._update_sysinfo_key(info, "child_protection", params["enable"])
# Vacuum special actions
elif method in ["playSelectAudio"]:
elif method == "remove_child_device_list":
return self._hub_remove_device(info, params)
# actions
elif method in [
"begin_scanning_child_device", # hub pairing
"add_child_device_list", # hub pairing
"remove_child_device_list", # hub pairing
"playSelectAudio", # vacuum special actions
]:
return {"error_code": 0}
elif method[:3] == "set":
target_method = f"get{method[3:]}"

View File

@@ -472,6 +472,24 @@
"setup_code": "00000000000",
"setup_payload": "00:0000000000000000000"
},
"get_scan_child_device_list": {
"child_device_list": [
{
"category": "subg.trigger.temp-hmdt-sensor",
"device_id": "REDACTED_1",
"device_model": "T315",
"name": "REDACTED_1"
},
{
"category": "subg.trigger.contact-sensor",
"device_id": "REDACTED_2",
"device_model": "T110",
"name": "REDACTED_2"
}
],
"scan_status": "scanning",
"scan_wait_time": 28
},
"get_support_alarm_type_list": {
"alarm_type_list": [
"Doorbell Ring 1",

View File

@@ -0,0 +1,69 @@
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="child_quick_setup", protocol_filter={"SMART"}
)
@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("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),
]
)
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(
"remove_child_device_list",
params={"child_device_list": [{"device_id": DUMMY_ID}]},
)

View File

@@ -988,3 +988,24 @@ async def test_dynamic_devices(dev: Device, caplog: pytest.LogCaptureFixture):
await dev.update()
assert "Could not find child id for device" not in caplog.text
@hubs_smart
async def test_unpair(dev: SmartDevice, mocker: MockerFixture):
"""Verify that unpair calls childsetup module."""
if not dev.children:
pytest.skip("device has no children")
child = dev.children[0]
assert child.parent is not None
assert Module.ChildSetup in dev.modules
cs = dev.modules[Module.ChildSetup]
unpair_call = mocker.spy(cs, "unpair")
unpair_feat = child.features.get("unpair")
assert unpair_feat
await unpair_feat.set_value(None)
unpair_call.assert_called_with(child.device_id)

View File

@@ -1,5 +1,4 @@
import json
import os
import re
from datetime import datetime
from unittest.mock import ANY, PropertyMock, patch
@@ -62,15 +61,6 @@ from .conftest import (
pytestmark = [pytest.mark.requires_dummy]
@pytest.fixture
def runner():
"""Runner fixture that unsets the KASA_ environment variables for tests."""
KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")}
runner = CliRunner(env=KASA_VARS)
return runner
async def test_help(runner):
"""Test that all the lazy modules are correctly names."""
res = await runner.invoke(cli, ["--help"])

View File

@@ -74,7 +74,7 @@ def test_feature_value_container(mocker, dummy_feature: Feature):
def test_prop(self):
return "dummy"
dummy_feature.container = DummyContainer()
dummy_feature.container = DummyContainer() # type: ignore[assignment]
dummy_feature.attribute_getter = "test_prop"
mock_dev_prop = mocker.patch.object(
@@ -191,7 +191,12 @@ async def test_feature_setters(dev: Device, mocker: MockerFixture):
exceptions = []
for feat in dev.features.values():
try:
with patch.object(feat.device.protocol, "query") as query:
prot = (
feat.container._device.protocol
if feat.container
else feat.device.protocol
)
with patch.object(prot, "query", name=feat.id) as query:
await _test_feature(feat, query)
# we allow our own exceptions to avoid mocking valid responses
except KasaException: