Merge remote-tracking branch 'upstream/master' into feat/power_protection

This commit is contained in:
Steven B
2025-01-23 15:17:26 +00:00
302 changed files with 28083 additions and 3492 deletions

View 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

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

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

View 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)

View 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")]}
)

View 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})

View File

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

View 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

View 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

View 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")

View 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"})

View File

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