mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-10-25 08:48:02 +00:00
Merge remote-tracking branch 'upstream/master' into feat/power_protection
This commit is contained in:
44
tests/smart/modules/test_childlock.py
Normal file
44
tests/smart/modules/test_childlock.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
|
||||
from kasa import Module
|
||||
from kasa.smart.modules import ChildLock
|
||||
|
||||
from ...device_fixtures import parametrize
|
||||
|
||||
childlock = parametrize(
|
||||
"has child lock",
|
||||
component_filter="button_and_led",
|
||||
protocol_filter={"SMART"},
|
||||
)
|
||||
|
||||
|
||||
@childlock
|
||||
@pytest.mark.parametrize(
|
||||
("feature", "prop_name", "type"),
|
||||
[
|
||||
("child_lock", "enabled", bool),
|
||||
],
|
||||
)
|
||||
async def test_features(dev, feature, prop_name, type):
|
||||
"""Test that features are registered and work as expected."""
|
||||
protect: ChildLock = dev.modules[Module.ChildLock]
|
||||
assert protect is not None
|
||||
|
||||
prop = getattr(protect, prop_name)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = protect._device.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
|
||||
@childlock
|
||||
async def test_enabled(dev):
|
||||
"""Test the API."""
|
||||
protect: ChildLock = dev.modules[Module.ChildLock]
|
||||
assert protect is not None
|
||||
|
||||
assert isinstance(protect.enabled, bool)
|
||||
await protect.set_enabled(False)
|
||||
await dev.update()
|
||||
assert protect.enabled is False
|
||||
69
tests/smart/modules/test_childsetup.py
Normal file
69
tests/smart/modules/test_childsetup.py
Normal 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}]},
|
||||
)
|
||||
248
tests/smart/modules/test_clean.py
Normal file
248
tests/smart/modules/test_clean.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules.clean import ErrorCode, Status
|
||||
|
||||
from ...device_fixtures import get_parent_and_child_modules, parametrize
|
||||
|
||||
clean = parametrize("clean module", component_filter="clean", protocol_filter={"SMART"})
|
||||
|
||||
|
||||
@clean
|
||||
@pytest.mark.parametrize(
|
||||
("feature", "prop_name", "type"),
|
||||
[
|
||||
("vacuum_status", "status", Status),
|
||||
("vacuum_error", "error", ErrorCode),
|
||||
("vacuum_fan_speed", "fan_speed_preset", str),
|
||||
("carpet_clean_mode", "carpet_clean_mode", str),
|
||||
("battery_level", "battery", int),
|
||||
],
|
||||
)
|
||||
async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type):
|
||||
"""Test that features are registered and work as expected."""
|
||||
clean = next(get_parent_and_child_modules(dev, Module.Clean))
|
||||
assert clean is not None
|
||||
|
||||
prop = getattr(clean, prop_name)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = clean._device.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("feature", "value", "method", "params"),
|
||||
[
|
||||
pytest.param(
|
||||
"vacuum_start",
|
||||
1,
|
||||
"setSwitchClean",
|
||||
{
|
||||
"clean_mode": 0,
|
||||
"clean_on": True,
|
||||
"clean_order": True,
|
||||
"force_clean": False,
|
||||
},
|
||||
id="vacuum_start",
|
||||
),
|
||||
pytest.param(
|
||||
"vacuum_pause", 1, "setRobotPause", {"pause": True}, id="vacuum_pause"
|
||||
),
|
||||
pytest.param(
|
||||
"vacuum_return_home",
|
||||
1,
|
||||
"setSwitchCharge",
|
||||
{"switch_charge": True},
|
||||
id="vacuum_return_home",
|
||||
),
|
||||
pytest.param(
|
||||
"vacuum_fan_speed",
|
||||
"Quiet",
|
||||
"setCleanAttr",
|
||||
{"suction": 1, "type": "global"},
|
||||
id="vacuum_fan_speed",
|
||||
),
|
||||
pytest.param(
|
||||
"carpet_clean_mode",
|
||||
"Boost",
|
||||
"setCarpetClean",
|
||||
{"carpet_clean_prefer": "boost"},
|
||||
id="carpet_clean_mode",
|
||||
),
|
||||
pytest.param(
|
||||
"clean_count",
|
||||
2,
|
||||
"setCleanAttr",
|
||||
{"clean_number": 2, "type": "global"},
|
||||
id="clean_count",
|
||||
),
|
||||
],
|
||||
)
|
||||
@clean
|
||||
async def test_actions(
|
||||
dev: SmartDevice,
|
||||
mocker: MockerFixture,
|
||||
feature: str,
|
||||
value: str | int,
|
||||
method: str,
|
||||
params: dict,
|
||||
):
|
||||
"""Test the clean actions."""
|
||||
clean = next(get_parent_and_child_modules(dev, Module.Clean))
|
||||
call = mocker.spy(clean, "call")
|
||||
|
||||
await dev.features[feature].set_value(value)
|
||||
call.assert_called_with(method, params)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("err_status", "error", "warning_msg"),
|
||||
[
|
||||
pytest.param([], ErrorCode.Ok, None, id="empty error"),
|
||||
pytest.param([0], ErrorCode.Ok, None, id="no error"),
|
||||
pytest.param([3], ErrorCode.MainBrushStuck, None, id="known error"),
|
||||
pytest.param(
|
||||
[123],
|
||||
ErrorCode.UnknownInternal,
|
||||
"Unknown error code, please create an issue describing the error: 123",
|
||||
id="unknown error",
|
||||
),
|
||||
pytest.param(
|
||||
[3, 4],
|
||||
ErrorCode.MainBrushStuck,
|
||||
"Multiple error codes, using the first one only: [3, 4]",
|
||||
id="multi-error",
|
||||
),
|
||||
],
|
||||
)
|
||||
@clean
|
||||
async def test_post_update_hook(
|
||||
dev: SmartDevice,
|
||||
err_status: list,
|
||||
error: ErrorCode,
|
||||
warning_msg: str | None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""Test that post update hook sets error states correctly."""
|
||||
clean = next(get_parent_and_child_modules(dev, Module.Clean))
|
||||
assert clean
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
# _post_update_hook will pop an item off the status list so create a copy.
|
||||
err_status = [e for e in err_status]
|
||||
clean.data["getVacStatus"]["err_status"] = err_status
|
||||
|
||||
await clean._post_update_hook()
|
||||
|
||||
assert clean._error_code is error
|
||||
|
||||
if error is not ErrorCode.Ok:
|
||||
assert clean.status is Status.Error
|
||||
|
||||
if warning_msg:
|
||||
assert warning_msg in caplog.text
|
||||
|
||||
# Check doesn't log twice
|
||||
caplog.clear()
|
||||
await clean._post_update_hook()
|
||||
|
||||
if warning_msg:
|
||||
assert warning_msg not in caplog.text
|
||||
|
||||
|
||||
@clean
|
||||
async def test_resume(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test that start calls resume if the state is paused."""
|
||||
clean = next(get_parent_and_child_modules(dev, Module.Clean))
|
||||
|
||||
call = mocker.spy(clean, "call")
|
||||
resume = mocker.spy(clean, "resume")
|
||||
|
||||
mocker.patch.object(
|
||||
type(clean),
|
||||
"status",
|
||||
new_callable=mocker.PropertyMock,
|
||||
return_value=Status.Paused,
|
||||
)
|
||||
await clean.start()
|
||||
|
||||
call.assert_called_with("setRobotPause", {"pause": False})
|
||||
resume.assert_awaited()
|
||||
|
||||
|
||||
@clean
|
||||
async def test_unknown_status(
|
||||
dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test that unknown status is logged."""
|
||||
clean = next(get_parent_and_child_modules(dev, Module.Clean))
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
clean.data["getVacStatus"]["status"] = 123
|
||||
|
||||
assert clean.status is Status.UnknownInternal
|
||||
assert "Got unknown status code: 123" in caplog.text
|
||||
|
||||
# Check only logs once
|
||||
caplog.clear()
|
||||
|
||||
assert clean.status is Status.UnknownInternal
|
||||
assert "Got unknown status code: 123" not in caplog.text
|
||||
|
||||
# Check logs again for other errors
|
||||
|
||||
caplog.clear()
|
||||
clean.data["getVacStatus"]["status"] = 123456
|
||||
|
||||
assert clean.status is Status.UnknownInternal
|
||||
assert "Got unknown status code: 123456" in caplog.text
|
||||
|
||||
|
||||
@clean
|
||||
@pytest.mark.parametrize(
|
||||
("setting", "value", "exc", "exc_message"),
|
||||
[
|
||||
pytest.param(
|
||||
"vacuum_fan_speed",
|
||||
"invalid speed",
|
||||
ValueError,
|
||||
"Invalid fan speed",
|
||||
id="vacuum_fan_speed",
|
||||
),
|
||||
pytest.param(
|
||||
"carpet_clean_mode",
|
||||
"invalid mode",
|
||||
ValueError,
|
||||
"Invalid carpet clean mode",
|
||||
id="carpet_clean_mode",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_invalid_settings(
|
||||
dev: SmartDevice,
|
||||
mocker: MockerFixture,
|
||||
setting: str,
|
||||
value: str,
|
||||
exc: type[Exception],
|
||||
exc_message: str,
|
||||
):
|
||||
"""Test invalid settings."""
|
||||
clean = next(get_parent_and_child_modules(dev, Module.Clean))
|
||||
|
||||
# Not using feature.set_value() as it checks for valid values
|
||||
setter_name = dev.features[setting].attribute_setter
|
||||
assert isinstance(setter_name, str)
|
||||
|
||||
setter = getattr(clean, setter_name)
|
||||
|
||||
with pytest.raises(exc, match=exc_message):
|
||||
await setter(value)
|
||||
59
tests/smart/modules/test_cleanrecords.py
Normal file
59
tests/smart/modules/test_cleanrecords.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
from ...device_fixtures import get_parent_and_child_modules, parametrize
|
||||
|
||||
cleanrecords = parametrize(
|
||||
"has clean records", component_filter="clean_percent", protocol_filter={"SMART"}
|
||||
)
|
||||
|
||||
|
||||
@cleanrecords
|
||||
@pytest.mark.parametrize(
|
||||
("feature", "prop_name", "type"),
|
||||
[
|
||||
("total_clean_area", "total_clean_area", int),
|
||||
("total_clean_time", "total_clean_time", timedelta),
|
||||
("last_clean_area", "last_clean_area", int),
|
||||
("last_clean_time", "last_clean_time", timedelta),
|
||||
("total_clean_count", "total_clean_count", int),
|
||||
("last_clean_timestamp", "last_clean_timestamp", datetime),
|
||||
],
|
||||
)
|
||||
async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type):
|
||||
"""Test that features are registered and work as expected."""
|
||||
records = next(get_parent_and_child_modules(dev, Module.CleanRecords))
|
||||
assert records is not None
|
||||
|
||||
prop = getattr(records, prop_name)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = records._device.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
|
||||
@cleanrecords
|
||||
async def test_timezone(dev: SmartDevice):
|
||||
"""Test that timezone is added to timestamps."""
|
||||
clean_records = next(get_parent_and_child_modules(dev, Module.CleanRecords))
|
||||
assert clean_records is not None
|
||||
|
||||
assert isinstance(clean_records.last_clean_timestamp, datetime)
|
||||
assert clean_records.last_clean_timestamp.tzinfo
|
||||
|
||||
# Check for zone info to ensure that this wasn't picking upthe default
|
||||
# of utc before the time module is updated.
|
||||
assert isinstance(clean_records.last_clean_timestamp.tzinfo, ZoneInfo)
|
||||
|
||||
for record in clean_records.parsed_data.records:
|
||||
assert isinstance(record.timestamp, datetime)
|
||||
assert record.timestamp.tzinfo
|
||||
assert isinstance(record.timestamp.tzinfo, ZoneInfo)
|
||||
53
tests/smart/modules/test_consumables.py
Normal file
53
tests/smart/modules/test_consumables.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules.consumables import CONSUMABLE_METAS
|
||||
|
||||
from ...device_fixtures import get_parent_and_child_modules, parametrize
|
||||
|
||||
consumables = parametrize(
|
||||
"has consumables", component_filter="consumables", protocol_filter={"SMART"}
|
||||
)
|
||||
|
||||
|
||||
@consumables
|
||||
@pytest.mark.parametrize(
|
||||
"consumable_name", [consumable.id for consumable in CONSUMABLE_METAS]
|
||||
)
|
||||
@pytest.mark.parametrize("postfix", ["used", "remaining"])
|
||||
async def test_features(dev: SmartDevice, consumable_name: str, postfix: str):
|
||||
"""Test that features are registered and work as expected."""
|
||||
consumables = next(get_parent_and_child_modules(dev, Module.Consumables))
|
||||
assert consumables is not None
|
||||
|
||||
feature_name = f"{consumable_name}_{postfix}"
|
||||
|
||||
feat = consumables._device.features[feature_name]
|
||||
assert isinstance(feat.value, timedelta)
|
||||
|
||||
|
||||
@consumables
|
||||
@pytest.mark.parametrize(
|
||||
("consumable_name", "data_key"),
|
||||
[(consumable.id, consumable.data_key) for consumable in CONSUMABLE_METAS],
|
||||
)
|
||||
async def test_erase(
|
||||
dev: SmartDevice, mocker: MockerFixture, consumable_name: str, data_key: str
|
||||
):
|
||||
"""Test autocollection switch."""
|
||||
consumables = next(get_parent_and_child_modules(dev, Module.Consumables))
|
||||
call = mocker.spy(consumables, "call")
|
||||
|
||||
feature_name = f"{consumable_name}_reset"
|
||||
feat = dev._features[feature_name]
|
||||
await feat.set_value(True)
|
||||
|
||||
call.assert_called_with(
|
||||
"resetConsumablesTime", {"reset_list": [data_key.removesuffix("_time")]}
|
||||
)
|
||||
92
tests/smart/modules/test_dustbin.py
Normal file
92
tests/smart/modules/test_dustbin.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules.dustbin import Mode
|
||||
|
||||
from ...device_fixtures import get_parent_and_child_modules, parametrize
|
||||
|
||||
dustbin = parametrize(
|
||||
"has dustbin", component_filter="dust_bucket", protocol_filter={"SMART"}
|
||||
)
|
||||
|
||||
|
||||
@dustbin
|
||||
@pytest.mark.parametrize(
|
||||
("feature", "prop_name", "type"),
|
||||
[
|
||||
("dustbin_autocollection_enabled", "auto_collection", bool),
|
||||
("dustbin_mode", "mode", str),
|
||||
],
|
||||
)
|
||||
async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type):
|
||||
"""Test that features are registered and work as expected."""
|
||||
dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin))
|
||||
assert dustbin is not None
|
||||
|
||||
prop = getattr(dustbin, prop_name)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = dustbin._device.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
|
||||
@dustbin
|
||||
async def test_dustbin_mode(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test dust mode."""
|
||||
dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin))
|
||||
call = mocker.spy(dustbin, "call")
|
||||
|
||||
mode_feature = dustbin._device.features["dustbin_mode"]
|
||||
assert dustbin.mode == mode_feature.value
|
||||
|
||||
new_mode = Mode.Max
|
||||
await dustbin.set_mode(new_mode.name)
|
||||
|
||||
params = dustbin._settings.copy()
|
||||
params["dust_collection_mode"] = new_mode.value
|
||||
|
||||
call.assert_called_with("setDustCollectionInfo", params)
|
||||
|
||||
await dev.update()
|
||||
|
||||
assert dustbin.mode == new_mode.name
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid auto/emptying mode speed"):
|
||||
await dustbin.set_mode("invalid")
|
||||
|
||||
|
||||
@dustbin
|
||||
async def test_autocollection(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test autocollection switch."""
|
||||
dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin))
|
||||
call = mocker.spy(dustbin, "call")
|
||||
|
||||
auto_collection = dustbin._device.features["dustbin_autocollection_enabled"]
|
||||
assert dustbin.auto_collection == auto_collection.value
|
||||
|
||||
await auto_collection.set_value(True)
|
||||
|
||||
params = dustbin._settings.copy()
|
||||
params["auto_dust_collection"] = True
|
||||
|
||||
call.assert_called_with("setDustCollectionInfo", params)
|
||||
|
||||
await dev.update()
|
||||
|
||||
assert dustbin.auto_collection is True
|
||||
|
||||
|
||||
@dustbin
|
||||
async def test_empty_dustbin(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test the empty dustbin feature."""
|
||||
dustbin = next(get_parent_and_child_modules(dev, Module.Dustbin))
|
||||
call = mocker.spy(dustbin, "call")
|
||||
|
||||
await dustbin.start_emptying()
|
||||
|
||||
call.assert_called_with("setSwitchDustCollection", {"switch_dust_collection": True})
|
||||
@@ -1,7 +1,14 @@
|
||||
import copy
|
||||
import logging
|
||||
from contextlib import nullcontext as does_not_raise
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kasa import Module, SmartDevice
|
||||
from kasa import DeviceError, Module
|
||||
from kasa.exceptions import SmartErrorCode
|
||||
from kasa.interfaces.energy import Energy
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules import Energy as SmartEnergyModule
|
||||
from tests.conftest import has_emeter_smart
|
||||
|
||||
@@ -19,3 +26,84 @@ async def test_supported(dev: SmartDevice):
|
||||
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False
|
||||
else:
|
||||
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True
|
||||
|
||||
|
||||
@has_emeter_smart
|
||||
async def test_get_energy_usage_error(
|
||||
dev: SmartDevice, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""Test errors on get_energy_usage."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
energy_module = dev.modules.get(Module.Energy)
|
||||
if not energy_module:
|
||||
pytest.skip(f"Energy module not supported for {dev}.")
|
||||
|
||||
version = dev._components["energy_monitoring"]
|
||||
|
||||
expected_raise = does_not_raise() if version > 1 else pytest.raises(DeviceError)
|
||||
if version > 1:
|
||||
expected = "get_energy_usage"
|
||||
expected_current_consumption = 2.002
|
||||
else:
|
||||
expected = "current_power"
|
||||
expected_current_consumption = None
|
||||
|
||||
assert expected in energy_module.data
|
||||
assert energy_module.current_consumption is not None
|
||||
assert energy_module.consumption_today is not None
|
||||
assert energy_module.consumption_this_month is not None
|
||||
|
||||
last_update = copy.deepcopy(dev._last_update)
|
||||
resp = copy.deepcopy(last_update)
|
||||
|
||||
if ed := resp.get("get_emeter_data"):
|
||||
ed["power_mw"] = 2002
|
||||
if cp := resp.get("get_current_power"):
|
||||
cp["current_power"] = 2.002
|
||||
resp["get_energy_usage"] = SmartErrorCode.JSON_DECODE_FAIL_ERROR
|
||||
|
||||
# version 1 only has get_energy_usage so module should raise an error if
|
||||
# version 1 and get_energy_usage is in error
|
||||
with patch.object(dev.protocol, "query", return_value=resp):
|
||||
await dev.update()
|
||||
|
||||
with expected_raise:
|
||||
assert "get_energy_usage" not in energy_module.data
|
||||
|
||||
assert energy_module.current_consumption == expected_current_consumption
|
||||
assert energy_module.consumption_today is None
|
||||
assert energy_module.consumption_this_month is None
|
||||
|
||||
msg = (
|
||||
f"Removed key get_energy_usage from response for device {dev.host}"
|
||||
" as it returned error: JSON_DECODE_FAIL_ERROR"
|
||||
)
|
||||
if version > 1:
|
||||
assert msg in caplog.text
|
||||
|
||||
# Now test with no get_emeter_data
|
||||
# This may not be valid scenario but we have a fallback to get_current_power
|
||||
# just in case that should be tested.
|
||||
caplog.clear()
|
||||
resp = copy.deepcopy(last_update)
|
||||
|
||||
if cp := resp.get("get_current_power"):
|
||||
cp["current_power"] = 2.002
|
||||
resp["get_energy_usage"] = SmartErrorCode.JSON_DECODE_FAIL_ERROR
|
||||
|
||||
# Remove get_emeter_data from the response and from the device which will
|
||||
# remember it otherwise.
|
||||
resp.pop("get_emeter_data", None)
|
||||
dev._last_update.pop("get_emeter_data", None)
|
||||
|
||||
with patch.object(dev.protocol, "query", return_value=resp):
|
||||
await dev.update()
|
||||
|
||||
with expected_raise:
|
||||
assert "get_energy_usage" not in energy_module.data
|
||||
|
||||
assert energy_module.current_consumption == expected_current_consumption
|
||||
|
||||
# message should only be logged once
|
||||
assert msg not in caplog.text
|
||||
|
||||
16
tests/smart/modules/test_homekit.py
Normal file
16
tests/smart/modules/test_homekit.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
from ...device_fixtures import parametrize
|
||||
|
||||
homekit = parametrize(
|
||||
"has homekit", component_filter="homekit", protocol_filter={"SMART"}
|
||||
)
|
||||
|
||||
|
||||
@homekit
|
||||
async def test_info(dev: SmartDevice):
|
||||
"""Test homekit info."""
|
||||
homekit = dev.modules.get(Module.HomeKit)
|
||||
assert homekit
|
||||
assert homekit.info
|
||||
20
tests/smart/modules/test_matter.py
Normal file
20
tests/smart/modules/test_matter.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
from ...device_fixtures import parametrize
|
||||
|
||||
matter = parametrize(
|
||||
"has matter", component_filter="matter", protocol_filter={"SMART", "SMARTCAM"}
|
||||
)
|
||||
|
||||
|
||||
@matter
|
||||
async def test_info(dev: SmartDevice):
|
||||
"""Test matter info."""
|
||||
matter = dev.modules.get(Module.Matter)
|
||||
assert matter
|
||||
assert matter.info
|
||||
setup_code = dev.features.get("matter_setup_code")
|
||||
assert setup_code
|
||||
setup_payload = dev.features.get("matter_setup_payload")
|
||||
assert setup_payload
|
||||
58
tests/smart/modules/test_mop.py
Normal file
58
tests/smart/modules/test_mop.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules.mop import Waterlevel
|
||||
|
||||
from ...device_fixtures import get_parent_and_child_modules, parametrize
|
||||
|
||||
mop = parametrize("has mop", component_filter="mop", protocol_filter={"SMART"})
|
||||
|
||||
|
||||
@mop
|
||||
@pytest.mark.parametrize(
|
||||
("feature", "prop_name", "type"),
|
||||
[
|
||||
("mop_attached", "mop_attached", bool),
|
||||
("mop_waterlevel", "waterlevel", str),
|
||||
],
|
||||
)
|
||||
async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type):
|
||||
"""Test that features are registered and work as expected."""
|
||||
mod = next(get_parent_and_child_modules(dev, Module.Mop))
|
||||
assert mod is not None
|
||||
|
||||
prop = getattr(mod, prop_name)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = mod._device.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
|
||||
@mop
|
||||
async def test_mop_waterlevel(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test dust mode."""
|
||||
mop_module = next(get_parent_and_child_modules(dev, Module.Mop))
|
||||
call = mocker.spy(mop_module, "call")
|
||||
|
||||
waterlevel = mop_module._device.features["mop_waterlevel"]
|
||||
assert mop_module.waterlevel == waterlevel.value
|
||||
|
||||
new_level = Waterlevel.High
|
||||
await mop_module.set_waterlevel(new_level.name)
|
||||
|
||||
params = mop_module._settings.copy()
|
||||
params["cistern"] = new_level.value
|
||||
|
||||
call.assert_called_with("setCleanAttr", params)
|
||||
|
||||
await dev.update()
|
||||
|
||||
assert mop_module.waterlevel == new_level.name
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid waterlevel"):
|
||||
await mop_module.set_waterlevel("invalid")
|
||||
71
tests/smart/modules/test_speaker.py
Normal file
71
tests/smart/modules/test_speaker.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import Module
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
from ...device_fixtures import get_parent_and_child_modules, parametrize
|
||||
|
||||
speaker = parametrize(
|
||||
"has speaker", component_filter="speaker", protocol_filter={"SMART"}
|
||||
)
|
||||
|
||||
|
||||
@speaker
|
||||
@pytest.mark.parametrize(
|
||||
("feature", "prop_name", "type"),
|
||||
[
|
||||
("volume", "volume", int),
|
||||
],
|
||||
)
|
||||
async def test_features(dev: SmartDevice, feature: str, prop_name: str, type: type):
|
||||
"""Test that features are registered and work as expected."""
|
||||
speaker = next(get_parent_and_child_modules(dev, Module.Speaker))
|
||||
assert speaker is not None
|
||||
|
||||
prop = getattr(speaker, prop_name)
|
||||
assert isinstance(prop, type)
|
||||
|
||||
feat = speaker._device.features[feature]
|
||||
assert feat.value == prop
|
||||
assert isinstance(feat.value, type)
|
||||
|
||||
|
||||
@speaker
|
||||
async def test_set_volume(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test speaker settings."""
|
||||
speaker = next(get_parent_and_child_modules(dev, Module.Speaker))
|
||||
assert speaker is not None
|
||||
|
||||
call = mocker.spy(speaker, "call")
|
||||
|
||||
volume = speaker._device.features["volume"]
|
||||
assert speaker.volume == volume.value
|
||||
|
||||
new_volume = 15
|
||||
await speaker.set_volume(new_volume)
|
||||
|
||||
call.assert_called_with("setVolume", {"volume": new_volume})
|
||||
|
||||
await dev.update()
|
||||
|
||||
assert speaker.volume == new_volume
|
||||
|
||||
with pytest.raises(ValueError, match="Volume must be between 0 and 100"):
|
||||
await speaker.set_volume(-10)
|
||||
|
||||
with pytest.raises(ValueError, match="Volume must be between 0 and 100"):
|
||||
await speaker.set_volume(110)
|
||||
|
||||
|
||||
@speaker
|
||||
async def test_locate(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test the locate method."""
|
||||
speaker = next(get_parent_and_child_modules(dev, Module.Speaker))
|
||||
call = mocker.spy(speaker, "call")
|
||||
|
||||
await speaker.locate()
|
||||
|
||||
call.assert_called_with("playSelectAudio", {"audio_type": "seek_me"})
|
||||
@@ -2,27 +2,43 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, cast
|
||||
from collections import OrderedDict
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import Device, KasaException, Module
|
||||
from kasa import Device, DeviceType, KasaException, Module
|
||||
from kasa.exceptions import DeviceError, SmartErrorCode
|
||||
from kasa.protocols.smartprotocol import _ChildProtocolWrapper
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules.energy import Energy
|
||||
from kasa.smart.smartmodule import SmartModule
|
||||
from kasa.smartcam import SmartCamDevice
|
||||
from tests.conftest import (
|
||||
DISCOVERY_MOCK_IP,
|
||||
device_smart,
|
||||
get_device_for_fixture_protocol,
|
||||
get_parent_and_child_modules,
|
||||
smart_discovery,
|
||||
)
|
||||
from tests.device_fixtures import variable_temp_smart
|
||||
from tests.device_fixtures import (
|
||||
hub_smartcam,
|
||||
hubs_smart,
|
||||
parametrize_combine,
|
||||
variable_temp_smart,
|
||||
)
|
||||
|
||||
from ..fakeprotocol_smart import FakeSmartTransport
|
||||
from ..fakeprotocol_smartcam import FakeSmartCamTransport
|
||||
|
||||
DUMMY_CHILD_REQUEST_PREFIX = "get_dummy_"
|
||||
|
||||
hub_all = parametrize_combine([hubs_smart, hub_smartcam])
|
||||
|
||||
|
||||
@device_smart
|
||||
@@ -51,13 +67,41 @@ async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture):
|
||||
await dev.update()
|
||||
|
||||
|
||||
@smart_discovery
|
||||
async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFixture):
|
||||
"""Test device type and repr when device not updated."""
|
||||
dev = SmartDevice(DISCOVERY_MOCK_IP)
|
||||
assert dev.device_type is DeviceType.Unknown
|
||||
assert repr(dev) == f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - update() needed>"
|
||||
|
||||
discovery_result = copy.deepcopy(discovery_mock.discovery_data["result"])
|
||||
|
||||
disco_model = discovery_result["device_model"]
|
||||
short_model, _, _ = disco_model.partition("(")
|
||||
dev.update_from_discover_info(discovery_result)
|
||||
assert dev.device_type is DeviceType.Unknown
|
||||
assert (
|
||||
repr(dev)
|
||||
== f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - None ({short_model}) - update() needed>"
|
||||
)
|
||||
discovery_result["device_type"] = "SMART.FOOBAR"
|
||||
dev.update_from_discover_info(discovery_result)
|
||||
dev._components = {"dummy": 1}
|
||||
assert dev.device_type is DeviceType.Plug
|
||||
assert (
|
||||
repr(dev)
|
||||
== f"<DeviceType.Plug at {DISCOVERY_MOCK_IP} - None ({short_model}) - update() needed>"
|
||||
)
|
||||
assert "Unknown device type, falling back to plug" in caplog.text
|
||||
|
||||
|
||||
@device_smart
|
||||
async def test_initial_update(dev: SmartDevice, mocker: MockerFixture):
|
||||
"""Test the initial update cycle."""
|
||||
# As the fixture data is already initialized, we reset the state for testing
|
||||
dev._components_raw = None
|
||||
dev._components = {}
|
||||
dev._modules = {}
|
||||
dev._modules = OrderedDict()
|
||||
dev._features = {}
|
||||
dev._children = {}
|
||||
dev._last_update = {}
|
||||
@@ -109,6 +153,7 @@ async def test_negotiate(dev: SmartDevice, mocker: MockerFixture):
|
||||
"get_child_device_list": None,
|
||||
}
|
||||
)
|
||||
await dev.update()
|
||||
assert len(dev._children) == dev.internal_state["get_child_device_list"]["sum"]
|
||||
|
||||
|
||||
@@ -183,6 +228,166 @@ async def test_update_module_update_delays(
|
||||
), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}"
|
||||
|
||||
|
||||
async def _get_child_responses(child_requests: list[dict[str, Any]], child_protocol):
|
||||
"""Get dummy responses for testing all child modules.
|
||||
|
||||
Even if they don't return really return query.
|
||||
"""
|
||||
child_req = {item["method"]: item.get("params") for item in child_requests}
|
||||
child_resp = {k: v for k, v in child_req.items() if k.startswith("get_dummy")}
|
||||
child_req = {
|
||||
k: v for k, v in child_req.items() if k.startswith("get_dummy") is False
|
||||
}
|
||||
resp = await child_protocol._query(child_req)
|
||||
resp = {**child_resp, **resp}
|
||||
return [
|
||||
{"method": k, "error_code": 0, "result": v or {"dummy": "dummy"}}
|
||||
for k, v in resp.items()
|
||||
]
|
||||
|
||||
|
||||
@hub_all
|
||||
@pytest.mark.xdist_group(name="caplog")
|
||||
async def test_hub_children_update_delays(
|
||||
dev: SmartDevice,
|
||||
mocker: MockerFixture,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
):
|
||||
"""Test that hub children use the correct delay."""
|
||||
if not dev.children:
|
||||
pytest.skip(f"Device {dev.model} does not have children.")
|
||||
# We need to have some modules initialized by now
|
||||
assert dev._modules
|
||||
|
||||
new_dev = type(dev)("127.0.0.1", protocol=dev.protocol)
|
||||
module_queries: dict[str, dict[str, dict]] = {}
|
||||
|
||||
# children should always update on first update
|
||||
await new_dev.update(update_children=False)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..fakeprotocol_smart import FakeSmartTransport
|
||||
|
||||
assert isinstance(dev.protocol._transport, FakeSmartTransport)
|
||||
if dev.protocol._transport.child_protocols:
|
||||
for child in new_dev.children:
|
||||
for modname, module in child._modules.items():
|
||||
if (
|
||||
not (q := module.query())
|
||||
and modname not in {"DeviceModule", "Light", "Battery", "Camera"}
|
||||
and not module.SYSINFO_LOOKUP_KEYS
|
||||
):
|
||||
q = {f"get_dummy_{modname}": {}}
|
||||
mocker.patch.object(module, "query", return_value=q)
|
||||
if q:
|
||||
queries = module_queries.setdefault(child.device_id, {})
|
||||
queries[cast(str, modname)] = q
|
||||
module._last_update_time = None
|
||||
|
||||
module_queries[""] = {
|
||||
cast(str, modname): q
|
||||
for modname, module in dev._modules.items()
|
||||
if (q := module.query())
|
||||
}
|
||||
|
||||
async def _query(request, *args, **kwargs):
|
||||
# If this is a child multipleRequest query return the error wrapped
|
||||
child_id = None
|
||||
# smart hub
|
||||
if (
|
||||
(cc := request.get("control_child"))
|
||||
and (child_id := cc.get("device_id"))
|
||||
and (requestData := cc["requestData"])
|
||||
and requestData["method"] == "multipleRequest"
|
||||
and (child_requests := requestData["params"]["requests"])
|
||||
):
|
||||
child_protocol = dev.protocol._transport.child_protocols[child_id]
|
||||
resp = await _get_child_responses(child_requests, child_protocol)
|
||||
return {"control_child": {"responseData": {"result": {"responses": resp}}}}
|
||||
# smartcam hub
|
||||
if (
|
||||
(mr := request.get("multipleRequest"))
|
||||
and (requests := mr.get("requests"))
|
||||
# assumes all requests for the same child
|
||||
and (
|
||||
child_id := next(iter(requests))
|
||||
.get("params", {})
|
||||
.get("childControl", {})
|
||||
.get("device_id")
|
||||
)
|
||||
and (
|
||||
child_requests := [
|
||||
cc["request_data"]
|
||||
for req in requests
|
||||
if (cc := req["params"].get("childControl"))
|
||||
]
|
||||
)
|
||||
):
|
||||
child_protocol = dev.protocol._transport.child_protocols[child_id]
|
||||
resp = await _get_child_responses(child_requests, child_protocol)
|
||||
resp = [{"result": {"response_data": resp}} for resp in resp]
|
||||
return {"multipleRequest": {"responses": resp}}
|
||||
|
||||
if child_id: # child single query
|
||||
child_protocol = dev.protocol._transport.child_protocols[child_id]
|
||||
resp_list = await _get_child_responses([requestData], child_protocol)
|
||||
resp = {"control_child": {"responseData": resp_list[0]}}
|
||||
else:
|
||||
resp = await dev.protocol._query(request, *args, **kwargs)
|
||||
|
||||
return resp
|
||||
|
||||
mocker.patch.object(new_dev.protocol, "query", side_effect=_query)
|
||||
|
||||
first_update_time = time.monotonic()
|
||||
assert new_dev._last_update_time == first_update_time
|
||||
|
||||
await new_dev.update()
|
||||
|
||||
for dev_id, modqueries in module_queries.items():
|
||||
check_dev = new_dev._children[dev_id] if dev_id else new_dev
|
||||
for modname in modqueries:
|
||||
mod = cast(SmartModule, check_dev.modules[modname])
|
||||
assert mod._last_update_time == first_update_time
|
||||
|
||||
for mod in new_dev.modules.values():
|
||||
mod.MINIMUM_UPDATE_INTERVAL_SECS = 5
|
||||
freezer.tick(180)
|
||||
|
||||
now = time.monotonic()
|
||||
await new_dev.update()
|
||||
|
||||
child_tick = max(
|
||||
module.MINIMUM_HUB_CHILD_UPDATE_INTERVAL_SECS
|
||||
for child in new_dev.children
|
||||
for module in child.modules.values()
|
||||
)
|
||||
|
||||
for dev_id, modqueries in module_queries.items():
|
||||
check_dev = new_dev._children[dev_id] if dev_id else new_dev
|
||||
for modname in modqueries:
|
||||
if modname in {"Firmware"}:
|
||||
continue
|
||||
mod = cast(SmartModule, check_dev.modules[modname])
|
||||
expected_update_time = first_update_time if dev_id else now
|
||||
assert mod._last_update_time == expected_update_time
|
||||
|
||||
freezer.tick(child_tick)
|
||||
|
||||
now = time.monotonic()
|
||||
await new_dev.update()
|
||||
|
||||
for dev_id, modqueries in module_queries.items():
|
||||
check_dev = new_dev._children[dev_id] if dev_id else new_dev
|
||||
for modname in modqueries:
|
||||
if modname in {"Firmware"}:
|
||||
continue
|
||||
mod = cast(SmartModule, check_dev.modules[modname])
|
||||
|
||||
assert mod._last_update_time == now
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("first_update"),
|
||||
[
|
||||
@@ -230,25 +435,82 @@ async def test_update_module_query_errors(
|
||||
new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol)
|
||||
if not first_update:
|
||||
await new_dev.update()
|
||||
freezer.tick(
|
||||
max(module.MINIMUM_UPDATE_INTERVAL_SECS for module in dev._modules.values())
|
||||
)
|
||||
freezer.tick(max(module.update_interval for module in dev._modules.values()))
|
||||
|
||||
module_queries = {
|
||||
modname: q
|
||||
module_queries: dict[str, dict[str, dict]] = {}
|
||||
if TYPE_CHECKING:
|
||||
from ..fakeprotocol_smart import FakeSmartTransport
|
||||
|
||||
assert isinstance(dev.protocol._transport, FakeSmartTransport)
|
||||
if dev.protocol._transport.child_protocols:
|
||||
for child in new_dev.children:
|
||||
for modname, module in child._modules.items():
|
||||
if (
|
||||
not (q := module.query())
|
||||
and modname not in {"DeviceModule", "Light"}
|
||||
and not module.SYSINFO_LOOKUP_KEYS
|
||||
):
|
||||
q = {f"get_dummy_{modname}": {}}
|
||||
mocker.patch.object(module, "query", return_value=q)
|
||||
if q:
|
||||
queries = module_queries.setdefault(child.device_id, {})
|
||||
queries[cast(str, modname)] = q
|
||||
|
||||
module_queries[""] = {
|
||||
cast(str, modname): q
|
||||
for modname, module in dev._modules.items()
|
||||
if (q := module.query()) and modname not in critical_modules
|
||||
}
|
||||
|
||||
raise_error = True
|
||||
|
||||
async def _query(request, *args, **kwargs):
|
||||
pass
|
||||
# If this is a childmultipleRequest query return the error wrapped
|
||||
child_id = None
|
||||
if (
|
||||
"component_nego" in request
|
||||
or "get_child_device_component_list" in request
|
||||
or "control_child" in request
|
||||
(cc := request.get("control_child"))
|
||||
and (child_id := cc.get("device_id"))
|
||||
and (requestData := cc["requestData"])
|
||||
and requestData["method"] == "multipleRequest"
|
||||
and (child_requests := requestData["params"]["requests"])
|
||||
):
|
||||
resp = await dev.protocol._query(request, *args, **kwargs)
|
||||
resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR
|
||||
if raise_error:
|
||||
if not isinstance(error_type, SmartErrorCode):
|
||||
raise TimeoutError()
|
||||
if len(child_requests) > 1:
|
||||
raise TimeoutError()
|
||||
|
||||
if raise_error:
|
||||
resp = {
|
||||
"method": child_requests[0]["method"],
|
||||
"error_code": error_type.value,
|
||||
}
|
||||
else:
|
||||
child_protocol = dev.protocol._transport.child_protocols[child_id]
|
||||
resp = await _get_child_responses(child_requests, child_protocol)
|
||||
return {"control_child": {"responseData": {"result": {"responses": resp}}}}
|
||||
|
||||
if (
|
||||
not raise_error
|
||||
or "component_nego" in request
|
||||
# allow the initial child device query
|
||||
or (
|
||||
"get_child_device_component_list" in request
|
||||
and "get_child_device_list" in request
|
||||
and len(request) == 2
|
||||
)
|
||||
):
|
||||
if child_id: # child single query
|
||||
child_protocol = dev.protocol._transport.child_protocols[child_id]
|
||||
resp_list = await _get_child_responses([requestData], child_protocol)
|
||||
resp = {"control_child": {"responseData": resp_list[0]}}
|
||||
else:
|
||||
resp = await dev.protocol._query(request, *args, **kwargs)
|
||||
if raise_error:
|
||||
resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR
|
||||
return resp
|
||||
|
||||
# Don't test for errors on get_device_info as that is likely terminal
|
||||
if len(request) == 1 and "get_device_info" in request:
|
||||
return await dev.protocol._query(request, *args, **kwargs)
|
||||
@@ -259,80 +521,77 @@ async def test_update_module_query_errors(
|
||||
raise TimeoutError("Dummy timeout")
|
||||
raise error_type
|
||||
|
||||
child_protocols = {
|
||||
cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol
|
||||
for child in dev.children
|
||||
}
|
||||
|
||||
async def _child_query(self, request, *args, **kwargs):
|
||||
return await child_protocols[self._device_id]._query(request, *args, **kwargs)
|
||||
|
||||
mocker.patch.object(new_dev.protocol, "query", side_effect=_query)
|
||||
# children not created yet so cannot patch.object
|
||||
mocker.patch(
|
||||
"kasa.protocols.smartprotocol._ChildProtocolWrapper.query", new=_child_query
|
||||
)
|
||||
|
||||
await new_dev.update()
|
||||
|
||||
msg = f"Error querying {new_dev.host} for modules"
|
||||
assert msg in caplog.text
|
||||
for modname in module_queries:
|
||||
mod = cast(SmartModule, new_dev.modules[modname])
|
||||
assert mod.disabled is False, f"{modname} disabled"
|
||||
assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS
|
||||
for mod_query in module_queries[modname]:
|
||||
if not first_update or mod_query not in first_update_queries:
|
||||
msg = f"Error querying {new_dev.host} individually for module query '{mod_query}"
|
||||
assert msg in caplog.text
|
||||
for dev_id, modqueries in module_queries.items():
|
||||
check_dev = new_dev._children[dev_id] if dev_id else new_dev
|
||||
for modname in modqueries:
|
||||
mod = cast(SmartModule, check_dev.modules[modname])
|
||||
if modname in {"DeviceModule"} or (
|
||||
hasattr(mod, "_state_in_sysinfo") and mod._state_in_sysinfo is True
|
||||
):
|
||||
continue
|
||||
assert mod.disabled is False, f"{modname} disabled"
|
||||
assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS
|
||||
for mod_query in modqueries[modname]:
|
||||
if not first_update or mod_query not in first_update_queries:
|
||||
msg = f"Error querying {new_dev.host} individually for module query '{mod_query}"
|
||||
assert msg in caplog.text
|
||||
|
||||
# Query again should not run for the modules
|
||||
caplog.clear()
|
||||
await new_dev.update()
|
||||
for modname in module_queries:
|
||||
mod = cast(SmartModule, new_dev.modules[modname])
|
||||
assert mod.disabled is False, f"{modname} disabled"
|
||||
for dev_id, modqueries in module_queries.items():
|
||||
check_dev = new_dev._children[dev_id] if dev_id else new_dev
|
||||
for modname in modqueries:
|
||||
mod = cast(SmartModule, check_dev.modules[modname])
|
||||
assert mod.disabled is False, f"{modname} disabled"
|
||||
|
||||
freezer.tick(SmartModule.UPDATE_INTERVAL_AFTER_ERROR_SECS)
|
||||
|
||||
caplog.clear()
|
||||
|
||||
if recover:
|
||||
mocker.patch.object(
|
||||
new_dev.protocol, "query", side_effect=new_dev.protocol._query
|
||||
)
|
||||
mocker.patch(
|
||||
"kasa.protocols.smartprotocol._ChildProtocolWrapper.query",
|
||||
new=_ChildProtocolWrapper._query,
|
||||
)
|
||||
raise_error = False
|
||||
|
||||
await new_dev.update()
|
||||
msg = f"Error querying {new_dev.host} for modules"
|
||||
if not recover:
|
||||
assert msg in caplog.text
|
||||
for modname in module_queries:
|
||||
mod = cast(SmartModule, new_dev.modules[modname])
|
||||
if not recover:
|
||||
assert mod.disabled is True, f"{modname} not disabled"
|
||||
assert mod._error_count == 2
|
||||
assert mod._last_update_error
|
||||
for mod_query in module_queries[modname]:
|
||||
if not first_update or mod_query not in first_update_queries:
|
||||
msg = f"Error querying {new_dev.host} individually for module query '{mod_query}"
|
||||
assert msg in caplog.text
|
||||
# Test one of the raise_if_update_error
|
||||
if mod.name == "Energy":
|
||||
emod = cast(Energy, mod)
|
||||
with pytest.raises(KasaException, match="Module update error"):
|
||||
assert emod.current_consumption is not None
|
||||
else:
|
||||
assert mod.disabled is False
|
||||
assert mod._error_count == 0
|
||||
assert mod._last_update_error is None
|
||||
# Test one of the raise_if_update_error doesn't raise
|
||||
if mod.name == "Energy":
|
||||
emod = cast(Energy, mod)
|
||||
assert emod.current_consumption is not None
|
||||
|
||||
for dev_id, modqueries in module_queries.items():
|
||||
check_dev = new_dev._children[dev_id] if dev_id else new_dev
|
||||
for modname in modqueries:
|
||||
mod = cast(SmartModule, check_dev.modules[modname])
|
||||
if modname in {"DeviceModule"} or (
|
||||
hasattr(mod, "_state_in_sysinfo") and mod._state_in_sysinfo is True
|
||||
):
|
||||
continue
|
||||
if not recover:
|
||||
assert mod.disabled is True, f"{modname} not disabled"
|
||||
assert mod._error_count == 2
|
||||
assert mod._last_update_error
|
||||
for mod_query in modqueries[modname]:
|
||||
if not first_update or mod_query not in first_update_queries:
|
||||
msg = f"Error querying {new_dev.host} individually for module query '{mod_query}"
|
||||
assert msg in caplog.text
|
||||
# Test one of the raise_if_update_error
|
||||
if mod.name == "Energy":
|
||||
emod = cast(Energy, mod)
|
||||
with pytest.raises(KasaException, match="Module update error"):
|
||||
assert emod.status is not None
|
||||
else:
|
||||
assert mod.disabled is False
|
||||
assert mod._error_count == 0
|
||||
assert mod._last_update_error is None
|
||||
# Test one of the raise_if_update_error doesn't raise
|
||||
if mod.name == "Energy":
|
||||
emod = cast(Energy, mod)
|
||||
assert emod.status is not None
|
||||
|
||||
|
||||
async def test_get_modules():
|
||||
@@ -441,4 +700,313 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt
|
||||
async def test_smart_temp_range(dev: Device):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
assert light.valid_temperature_range
|
||||
color_temp_feat = light.get_feature("color_temp")
|
||||
assert color_temp_feat
|
||||
assert color_temp_feat.range
|
||||
|
||||
|
||||
@device_smart
|
||||
async def test_initialize_modules_sysinfo_lookup_keys(
|
||||
dev: SmartDevice, mocker: MockerFixture
|
||||
):
|
||||
"""Test that matching modules using SYSINFO_LOOKUP_KEYS are initialized correctly."""
|
||||
|
||||
class AvailableKey(SmartModule):
|
||||
SYSINFO_LOOKUP_KEYS = ["device_id"]
|
||||
|
||||
class NonExistingKey(SmartModule):
|
||||
SYSINFO_LOOKUP_KEYS = ["this_does_not_exist"]
|
||||
|
||||
# The __init_subclass__ hook in smartmodule checks the path,
|
||||
# so we have to manually add these for testing.
|
||||
mocker.patch.dict(
|
||||
"kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES",
|
||||
{
|
||||
AvailableKey._module_name(): AvailableKey,
|
||||
NonExistingKey._module_name(): NonExistingKey,
|
||||
},
|
||||
)
|
||||
|
||||
# We have an already initialized device, so we try to initialize the modules again
|
||||
await dev._initialize_modules()
|
||||
|
||||
assert "AvailableKey" in dev.modules
|
||||
assert "NonExistingKey" not in dev.modules
|
||||
|
||||
|
||||
@device_smart
|
||||
async def test_initialize_modules_required_component(
|
||||
dev: SmartDevice, mocker: MockerFixture
|
||||
):
|
||||
"""Test that matching modules using REQUIRED_COMPONENT are initialized correctly."""
|
||||
|
||||
class AvailableComponent(SmartModule):
|
||||
REQUIRED_COMPONENT = "device"
|
||||
|
||||
class NonExistingComponent(SmartModule):
|
||||
REQUIRED_COMPONENT = "this_does_not_exist"
|
||||
|
||||
# The __init_subclass__ hook in smartmodule checks the path,
|
||||
# so we have to manually add these for testing.
|
||||
mocker.patch.dict(
|
||||
"kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES",
|
||||
{
|
||||
AvailableComponent._module_name(): AvailableComponent,
|
||||
NonExistingComponent._module_name(): NonExistingComponent,
|
||||
},
|
||||
)
|
||||
|
||||
# We have an already initialized device, so we try to initialize the modules again
|
||||
await dev._initialize_modules()
|
||||
|
||||
assert "AvailableComponent" in dev.modules
|
||||
assert "NonExistingComponent" not in dev.modules
|
||||
|
||||
|
||||
async def test_smartmodule_query():
|
||||
"""Test that a module that doesn't set QUERY_GETTER_NAME has empty query."""
|
||||
|
||||
class DummyModule(SmartModule):
|
||||
pass
|
||||
|
||||
dummy_device = await get_device_for_fixture_protocol(
|
||||
"KS240(US)_1.0_1.0.5.json", "SMART"
|
||||
)
|
||||
mod = DummyModule(dummy_device, "dummy")
|
||||
assert mod.query() == {}
|
||||
|
||||
|
||||
@hub_all
|
||||
@pytest.mark.xdist_group(name="caplog")
|
||||
@pytest.mark.requires_dummy
|
||||
async def test_dynamic_devices(dev: Device, caplog: pytest.LogCaptureFixture):
|
||||
"""Test dynamic child devices."""
|
||||
if not dev.children:
|
||||
pytest.skip(f"Device {dev.model} does not have children.")
|
||||
|
||||
transport = dev.protocol._transport
|
||||
assert isinstance(transport, FakeSmartCamTransport | FakeSmartTransport)
|
||||
|
||||
lu = dev._last_update
|
||||
assert lu
|
||||
child_device_info = lu.get("getChildDeviceList", lu.get("get_child_device_list"))
|
||||
assert child_device_info
|
||||
|
||||
child_device_components = lu.get(
|
||||
"getChildDeviceComponentList", lu.get("get_child_device_component_list")
|
||||
)
|
||||
assert child_device_components
|
||||
|
||||
mock_child_device_info = copy.deepcopy(child_device_info)
|
||||
mock_child_device_components = copy.deepcopy(child_device_components)
|
||||
|
||||
first_child = child_device_info["child_device_list"][0]
|
||||
first_child_device_id = first_child["device_id"]
|
||||
|
||||
first_child_components = next(
|
||||
iter(
|
||||
[
|
||||
cc
|
||||
for cc in child_device_components["child_component_list"]
|
||||
if cc["device_id"] == first_child_device_id
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
first_child_fake_transport = transport.child_protocols[first_child_device_id]
|
||||
|
||||
# Test adding devices
|
||||
start_child_count = len(dev.children)
|
||||
added_ids = []
|
||||
for i in range(1, 3):
|
||||
new_child = copy.deepcopy(first_child)
|
||||
new_child_components = copy.deepcopy(first_child_components)
|
||||
|
||||
mock_device_id = f"mock_child_device_id_{i}"
|
||||
|
||||
transport.child_protocols[mock_device_id] = first_child_fake_transport
|
||||
new_child["device_id"] = mock_device_id
|
||||
new_child_components["device_id"] = mock_device_id
|
||||
|
||||
added_ids.append(mock_device_id)
|
||||
mock_child_device_info["child_device_list"].append(new_child)
|
||||
mock_child_device_components["child_component_list"].append(
|
||||
new_child_components
|
||||
)
|
||||
|
||||
def mock_get_child_device_queries(method, params):
|
||||
if method in {"getChildDeviceList", "get_child_device_list"}:
|
||||
result = mock_child_device_info
|
||||
if method in {"getChildDeviceComponentList", "get_child_device_component_list"}:
|
||||
result = mock_child_device_components
|
||||
return {"result": result, "error_code": 0}
|
||||
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
for added_id in added_ids:
|
||||
assert added_id in dev._children
|
||||
expected_new_length = start_child_count + len(added_ids)
|
||||
assert len(dev.children) == expected_new_length
|
||||
|
||||
# Test removing devices
|
||||
mock_child_device_info["child_device_list"] = [
|
||||
info
|
||||
for info in mock_child_device_info["child_device_list"]
|
||||
if info["device_id"] != first_child_device_id
|
||||
]
|
||||
mock_child_device_components["child_component_list"] = [
|
||||
cc
|
||||
for cc in mock_child_device_components["child_component_list"]
|
||||
if cc["device_id"] != first_child_device_id
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
expected_new_length -= 1
|
||||
assert len(dev.children) == expected_new_length
|
||||
|
||||
# Test no child devices
|
||||
|
||||
mock_child_device_info["child_device_list"] = []
|
||||
mock_child_device_components["child_component_list"] = []
|
||||
mock_child_device_info["sum"] = 0
|
||||
mock_child_device_components["sum"] = 0
|
||||
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
assert len(dev.children) == 0
|
||||
|
||||
# Logging tests are only for smartcam hubs as smart hubs do not test categories
|
||||
if not isinstance(dev, SmartCamDevice):
|
||||
return
|
||||
|
||||
# setup
|
||||
mock_child = copy.deepcopy(first_child)
|
||||
mock_components = copy.deepcopy(first_child_components)
|
||||
|
||||
mock_child_device_info["child_device_list"] = [mock_child]
|
||||
mock_child_device_components["child_component_list"] = [mock_components]
|
||||
mock_child_device_info["sum"] = 1
|
||||
mock_child_device_components["sum"] = 1
|
||||
|
||||
# Test can't find matching components
|
||||
|
||||
mock_child["device_id"] = "no_comps_1"
|
||||
mock_components["device_id"] = "no_comps_2"
|
||||
|
||||
caplog.set_level("DEBUG")
|
||||
caplog.clear()
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
assert "Could not find child components for device" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
|
||||
# Test doesn't log multiple
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
assert "Could not find child components for device" not in caplog.text
|
||||
|
||||
# Test invalid category
|
||||
|
||||
mock_child["device_id"] = "invalid_cat"
|
||||
mock_components["device_id"] = "invalid_cat"
|
||||
mock_child["category"] = "foobar"
|
||||
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
assert "Child device type not supported" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
|
||||
# Test doesn't log multiple
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
assert "Child device type not supported" not in caplog.text
|
||||
|
||||
# Test no category
|
||||
|
||||
mock_child["device_id"] = "no_cat"
|
||||
mock_components["device_id"] = "no_cat"
|
||||
mock_child.pop("category")
|
||||
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
assert "Child device type not supported" in caplog.text
|
||||
|
||||
# Test only log once
|
||||
|
||||
caplog.clear()
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
assert "Child device type not supported" not in caplog.text
|
||||
|
||||
# Test no device_id
|
||||
|
||||
mock_child.pop("device_id")
|
||||
|
||||
caplog.clear()
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
await dev.update()
|
||||
|
||||
assert "Could not find child id for device" in caplog.text
|
||||
|
||||
# Test only log once
|
||||
|
||||
caplog.clear()
|
||||
with patch.object(
|
||||
transport, "get_child_device_queries", side_effect=mock_get_child_device_queries
|
||||
):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user