2024-03-15 15:55:48 +00:00
|
|
|
"""Tests for SMART devices."""
|
2024-04-16 18:21:20 +00:00
|
|
|
|
2024-04-17 13:39:24 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2024-02-15 18:10:34 +00:00
|
|
|
import logging
|
2024-07-11 15:21:59 +00:00
|
|
|
import time
|
2024-07-04 07:02:50 +00:00
|
|
|
from typing import Any, cast
|
2024-04-23 11:56:32 +00:00
|
|
|
from unittest.mock import patch
|
2020-05-27 14:55:18 +00:00
|
|
|
|
2024-03-15 16:18:13 +00:00
|
|
|
import pytest
|
2024-07-11 15:21:59 +00:00
|
|
|
from freezegun.api import FrozenDateTimeFactory
|
2024-03-15 16:18:13 +00:00
|
|
|
from pytest_mock import MockerFixture
|
2020-05-27 14:55:18 +00:00
|
|
|
|
2024-06-10 14:47:00 +00:00
|
|
|
from kasa import Device, KasaException, Module
|
2024-02-15 18:10:34 +00:00
|
|
|
from kasa.exceptions import SmartErrorCode
|
2024-04-29 17:19:44 +00:00
|
|
|
from kasa.smart import SmartDevice
|
2020-05-27 14:55:18 +00:00
|
|
|
|
2024-02-15 18:10:34 +00:00
|
|
|
from .conftest import (
|
|
|
|
device_smart,
|
2024-04-30 16:42:53 +00:00
|
|
|
get_device_for_fixture_protocol,
|
2024-06-10 04:59:37 +00:00
|
|
|
get_parent_and_child_modules,
|
2024-02-04 15:20:08 +00:00
|
|
|
)
|
2023-09-13 13:46:38 +00:00
|
|
|
|
2020-05-27 14:55:18 +00:00
|
|
|
|
2024-02-15 18:10:34 +00:00
|
|
|
@device_smart
|
2024-02-19 17:01:31 +00:00
|
|
|
async def test_try_get_response(dev: SmartDevice, caplog):
|
2024-02-15 18:10:34 +00:00
|
|
|
mock_response: dict = {
|
2024-02-19 17:01:31 +00:00
|
|
|
"get_device_info": SmartErrorCode.PARAMS_ERROR,
|
2024-02-15 18:10:34 +00:00
|
|
|
}
|
|
|
|
caplog.set_level(logging.DEBUG)
|
2024-02-19 17:01:31 +00:00
|
|
|
dev._try_get_response(mock_response, "get_device_info", {})
|
|
|
|
msg = "Error PARAMS_ERROR(-1008) getting request get_device_info for device 127.0.0.123"
|
2024-02-15 18:10:34 +00:00
|
|
|
assert msg in caplog.text
|
|
|
|
|
|
|
|
|
|
|
|
@device_smart
|
2024-03-15 16:18:13 +00:00
|
|
|
async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture):
|
2024-02-15 18:10:34 +00:00
|
|
|
mock_response: dict = {
|
|
|
|
"get_device_usage": {},
|
|
|
|
"get_device_time": {},
|
|
|
|
}
|
|
|
|
msg = f"get_device_info not found in {mock_response} for device 127.0.0.123"
|
2024-06-19 18:24:12 +00:00
|
|
|
with (
|
|
|
|
mocker.patch.object(dev.protocol, "query", return_value=mock_response),
|
|
|
|
pytest.raises(KasaException, match=msg),
|
|
|
|
):
|
2024-02-15 18:10:34 +00:00
|
|
|
await dev.update()
|
2024-03-15 16:18:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
@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
|
2024-05-19 09:18:17 +00:00
|
|
|
dev._components = {}
|
|
|
|
dev._modules = {}
|
2024-03-15 16:18:13 +00:00
|
|
|
dev._features = {}
|
2024-05-19 09:18:17 +00:00
|
|
|
dev._children = {}
|
2024-07-11 15:21:59 +00:00
|
|
|
dev._last_update = {}
|
|
|
|
dev._last_update_time = None
|
2024-03-15 16:18:13 +00:00
|
|
|
|
|
|
|
negotiate = mocker.spy(dev, "_negotiate")
|
|
|
|
initialize_modules = mocker.spy(dev, "_initialize_modules")
|
|
|
|
initialize_features = mocker.spy(dev, "_initialize_features")
|
|
|
|
|
|
|
|
# Perform two updates and verify that initialization is only done once
|
|
|
|
await dev.update()
|
|
|
|
await dev.update()
|
|
|
|
|
|
|
|
negotiate.assert_called_once()
|
|
|
|
assert dev._components_raw is not None
|
|
|
|
initialize_modules.assert_called_once()
|
|
|
|
assert dev.modules
|
|
|
|
initialize_features.assert_called_once()
|
|
|
|
assert dev.features
|
|
|
|
|
|
|
|
|
|
|
|
@device_smart
|
|
|
|
async def test_negotiate(dev: SmartDevice, mocker: MockerFixture):
|
|
|
|
"""Test that the initial negotiation performs expected steps."""
|
|
|
|
# As the fixture data is already initialized, we reset the state for testing
|
|
|
|
dev._components_raw = None
|
|
|
|
dev._children = {}
|
|
|
|
|
|
|
|
query = mocker.spy(dev.protocol, "query")
|
|
|
|
initialize_children = mocker.spy(dev, "_initialize_children")
|
|
|
|
await dev._negotiate()
|
|
|
|
|
|
|
|
# Check that we got the initial negotiation call
|
2024-04-23 11:56:32 +00:00
|
|
|
query.assert_any_call(
|
|
|
|
{
|
|
|
|
"component_nego": None,
|
|
|
|
"get_device_info": None,
|
|
|
|
"get_connect_cloud_state": None,
|
|
|
|
}
|
|
|
|
)
|
2024-03-15 16:18:13 +00:00
|
|
|
assert dev._components_raw
|
|
|
|
|
|
|
|
# Check the children are created, if device supports them
|
|
|
|
if "child_device" in dev._components:
|
|
|
|
initialize_children.assert_called_once()
|
|
|
|
query.assert_any_call(
|
|
|
|
{
|
|
|
|
"get_child_device_component_list": None,
|
|
|
|
"get_child_device_list": None,
|
|
|
|
}
|
|
|
|
)
|
2024-04-24 18:17:49 +00:00
|
|
|
assert len(dev._children) == dev.internal_state["get_child_device_list"]["sum"]
|
2024-03-15 16:18:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
@device_smart
|
|
|
|
async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
|
|
|
|
"""Test that the regular update uses queries from all supported modules."""
|
|
|
|
# We need to have some modules initialized by now
|
2024-04-29 16:34:20 +00:00
|
|
|
assert dev._modules
|
2024-07-11 15:21:59 +00:00
|
|
|
# Reset last update so all modules will query
|
|
|
|
for mod in dev._modules.values():
|
|
|
|
mod._last_update_time = None
|
2024-03-15 16:18:13 +00:00
|
|
|
|
2024-04-24 18:17:49 +00:00
|
|
|
device_queries: dict[SmartDevice, dict[str, Any]] = {}
|
2024-04-29 16:34:20 +00:00
|
|
|
for mod in dev._modules.values():
|
2024-04-24 18:17:49 +00:00
|
|
|
device_queries.setdefault(mod._device, {}).update(mod.query())
|
2024-06-10 14:47:00 +00:00
|
|
|
# Hubs do not query child modules by default.
|
|
|
|
if dev.device_type != Device.Type.Hub:
|
|
|
|
for child in dev.children:
|
|
|
|
for mod in child.modules.values():
|
|
|
|
device_queries.setdefault(mod._device, {}).update(mod.query())
|
2024-04-24 18:17:49 +00:00
|
|
|
|
|
|
|
spies = {}
|
2024-04-29 16:34:20 +00:00
|
|
|
for device in device_queries:
|
|
|
|
spies[device] = mocker.spy(device.protocol, "query")
|
2024-03-15 16:18:13 +00:00
|
|
|
|
2024-04-24 18:17:49 +00:00
|
|
|
await dev.update()
|
2024-04-29 16:34:20 +00:00
|
|
|
for device in device_queries:
|
|
|
|
if device_queries[device]:
|
2024-06-10 14:47:00 +00:00
|
|
|
# Need assert any here because the child device updates use the parent's protocol
|
|
|
|
spies[device].assert_any_call(device_queries[device])
|
2024-04-24 18:17:49 +00:00
|
|
|
else:
|
2024-04-29 16:34:20 +00:00
|
|
|
spies[device].assert_not_called()
|
2024-04-17 11:33:10 +00:00
|
|
|
|
|
|
|
|
2024-07-04 07:02:50 +00:00
|
|
|
@device_smart
|
|
|
|
async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture):
|
|
|
|
"""Test that modules that error are disabled / removed."""
|
|
|
|
# We need to have some modules initialized by now
|
|
|
|
assert dev._modules
|
|
|
|
|
|
|
|
critical_modules = {Module.DeviceModule, Module.ChildDevice}
|
2024-07-11 15:21:59 +00:00
|
|
|
not_disabling_modules = {Module.Cloud}
|
2024-07-04 07:02:50 +00:00
|
|
|
|
|
|
|
new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol)
|
|
|
|
|
|
|
|
module_queries = {
|
|
|
|
modname: q
|
|
|
|
for modname, module in dev._modules.items()
|
|
|
|
if (q := module.query()) and modname not in critical_modules
|
|
|
|
}
|
|
|
|
child_module_queries = {
|
|
|
|
modname: q
|
|
|
|
for child in dev.children
|
|
|
|
for modname, module in child._modules.items()
|
|
|
|
if (q := module.query()) and modname not in critical_modules
|
|
|
|
}
|
|
|
|
all_queries_names = {
|
|
|
|
key for mod_query in module_queries.values() for key in mod_query
|
|
|
|
}
|
|
|
|
all_child_queries_names = {
|
|
|
|
key for mod_query in child_module_queries.values() for key in mod_query
|
|
|
|
}
|
|
|
|
|
|
|
|
async def _query(request, *args, **kwargs):
|
|
|
|
responses = await dev.protocol._query(request, *args, **kwargs)
|
|
|
|
for k in responses:
|
|
|
|
if k in all_queries_names:
|
|
|
|
responses[k] = SmartErrorCode.PARAMS_ERROR
|
|
|
|
return responses
|
|
|
|
|
|
|
|
async def _child_query(self, request, *args, **kwargs):
|
|
|
|
responses = await child_protocols[self._device_id]._query(
|
|
|
|
request, *args, **kwargs
|
|
|
|
)
|
|
|
|
for k in responses:
|
|
|
|
if k in all_child_queries_names:
|
|
|
|
responses[k] = SmartErrorCode.PARAMS_ERROR
|
|
|
|
return responses
|
|
|
|
|
|
|
|
mocker.patch.object(new_dev.protocol, "query", side_effect=_query)
|
|
|
|
|
|
|
|
from kasa.smartprotocol import _ChildProtocolWrapper
|
|
|
|
|
|
|
|
child_protocols = {
|
|
|
|
cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol
|
|
|
|
for child in dev.children
|
|
|
|
}
|
|
|
|
# children not created yet so cannot patch.object
|
|
|
|
mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query)
|
|
|
|
|
|
|
|
await new_dev.update()
|
|
|
|
for modname in module_queries:
|
|
|
|
no_disable = modname in not_disabling_modules
|
|
|
|
mod_present = modname in new_dev._modules
|
|
|
|
assert (
|
|
|
|
mod_present is no_disable
|
|
|
|
), f"{modname} present {mod_present} when no_disable {no_disable}"
|
|
|
|
|
|
|
|
for modname in child_module_queries:
|
|
|
|
no_disable = modname in not_disabling_modules
|
|
|
|
mod_present = any(modname in child._modules for child in new_dev.children)
|
|
|
|
assert (
|
|
|
|
mod_present is no_disable
|
|
|
|
), f"{modname} present {mod_present} when no_disable {no_disable}"
|
|
|
|
|
|
|
|
|
2024-07-11 15:21:59 +00:00
|
|
|
@device_smart
|
|
|
|
async def test_update_module_update_delays(
|
|
|
|
dev: SmartDevice,
|
|
|
|
mocker: MockerFixture,
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
freezer: FrozenDateTimeFactory,
|
|
|
|
):
|
|
|
|
"""Test that modules that disabled / removed on query failures."""
|
|
|
|
# We need to have some modules initialized by now
|
|
|
|
assert dev._modules
|
|
|
|
|
|
|
|
new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol)
|
|
|
|
await new_dev.update()
|
|
|
|
first_update_time = time.time()
|
|
|
|
assert new_dev._last_update_time == first_update_time
|
|
|
|
for module in new_dev.modules.values():
|
|
|
|
if module.query():
|
|
|
|
assert module._last_update_time == first_update_time
|
|
|
|
|
|
|
|
seconds = 0
|
|
|
|
tick = 30
|
|
|
|
while seconds <= 180:
|
|
|
|
seconds += tick
|
|
|
|
freezer.tick(tick)
|
|
|
|
|
|
|
|
now = time.time()
|
|
|
|
await new_dev.update()
|
|
|
|
for module in new_dev.modules.values():
|
|
|
|
mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS
|
|
|
|
if module.query():
|
|
|
|
expected_update_time = (
|
|
|
|
now if mod_delay == 0 else now - (seconds % mod_delay)
|
|
|
|
)
|
|
|
|
|
|
|
|
assert (
|
|
|
|
module._last_update_time == expected_update_time
|
|
|
|
), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}"
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
("first_update"),
|
|
|
|
[
|
|
|
|
pytest.param(True, id="First update true"),
|
|
|
|
pytest.param(False, id="First update false"),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
@device_smart
|
|
|
|
async def test_update_module_query_errors(
|
|
|
|
dev: SmartDevice,
|
|
|
|
mocker: MockerFixture,
|
|
|
|
caplog: pytest.LogCaptureFixture,
|
|
|
|
freezer: FrozenDateTimeFactory,
|
|
|
|
first_update,
|
|
|
|
):
|
|
|
|
"""Test that modules that disabled / removed on query failures."""
|
|
|
|
# We need to have some modules initialized by now
|
|
|
|
assert dev._modules
|
|
|
|
|
|
|
|
first_update_queries = {"get_device_info", "get_connect_cloud_state"}
|
|
|
|
|
|
|
|
critical_modules = {Module.DeviceModule, Module.ChildDevice}
|
|
|
|
not_disabling_modules = {Module.Cloud}
|
|
|
|
|
|
|
|
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())
|
|
|
|
)
|
|
|
|
|
|
|
|
module_queries = {
|
|
|
|
modname: q
|
|
|
|
for modname, module in dev._modules.items()
|
|
|
|
if (q := module.query()) and modname not in critical_modules
|
|
|
|
}
|
|
|
|
|
|
|
|
async def _query(request, *args, **kwargs):
|
|
|
|
if (
|
|
|
|
"component_nego" in request
|
|
|
|
or "get_child_device_component_list" in request
|
|
|
|
or "control_child" in request
|
|
|
|
):
|
|
|
|
return await dev.protocol._query(request, *args, **kwargs)
|
|
|
|
if len(request) == 1 and "get_device_info" in request:
|
|
|
|
return await dev.protocol._query(request, *args, **kwargs)
|
|
|
|
|
|
|
|
raise TimeoutError("Dummy timeout")
|
|
|
|
|
|
|
|
from kasa.smartprotocol import _ChildProtocolWrapper
|
|
|
|
|
|
|
|
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.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:
|
|
|
|
no_disable = modname in not_disabling_modules
|
|
|
|
mod_present = modname in new_dev._modules
|
|
|
|
assert (
|
|
|
|
mod_present is no_disable
|
|
|
|
), f"{modname} present {mod_present} when no_disable {no_disable}"
|
|
|
|
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
|
|
|
|
|
|
|
|
|
2024-05-03 15:01:21 +00:00
|
|
|
async def test_get_modules():
|
2024-05-10 18:29:28 +00:00
|
|
|
"""Test getting modules for child and parent modules."""
|
2024-04-30 16:42:53 +00:00
|
|
|
dummy_device = await get_device_for_fixture_protocol(
|
|
|
|
"KS240(US)_1.0_1.0.5.json", "SMART"
|
|
|
|
)
|
2024-05-11 18:28:18 +00:00
|
|
|
from kasa.smart.modules import Cloud
|
2024-05-03 15:01:21 +00:00
|
|
|
|
|
|
|
# Modules on device
|
2024-05-11 18:28:18 +00:00
|
|
|
module = dummy_device.modules.get("Cloud")
|
2024-04-30 16:42:53 +00:00
|
|
|
assert module
|
|
|
|
assert module._device == dummy_device
|
2024-05-11 18:28:18 +00:00
|
|
|
assert isinstance(module, Cloud)
|
2024-04-30 16:42:53 +00:00
|
|
|
|
2024-05-10 18:29:28 +00:00
|
|
|
module = dummy_device.modules.get(Module.Cloud)
|
2024-05-03 15:01:21 +00:00
|
|
|
assert module
|
|
|
|
assert module._device == dummy_device
|
2024-05-11 18:28:18 +00:00
|
|
|
assert isinstance(module, Cloud)
|
2024-05-03 15:01:21 +00:00
|
|
|
|
|
|
|
# Modules on child
|
2024-05-11 18:28:18 +00:00
|
|
|
module = dummy_device.modules.get("Fan")
|
2024-06-10 04:59:37 +00:00
|
|
|
assert module is None
|
|
|
|
module = next(get_parent_and_child_modules(dummy_device, "Fan"))
|
2024-05-03 15:01:21 +00:00
|
|
|
assert module
|
|
|
|
assert module._device != dummy_device
|
|
|
|
assert module._device._parent == dummy_device
|
|
|
|
|
|
|
|
# Invalid modules
|
2024-05-10 18:29:28 +00:00
|
|
|
module = dummy_device.modules.get("DummyModule")
|
2024-04-30 16:42:53 +00:00
|
|
|
assert module is None
|
|
|
|
|
2024-05-10 18:29:28 +00:00
|
|
|
module = dummy_device.modules.get(Module.IotAmbientLight)
|
2024-05-03 15:01:21 +00:00
|
|
|
assert module is None
|
|
|
|
|
2024-04-30 16:42:53 +00:00
|
|
|
|
2024-04-23 11:56:32 +00:00
|
|
|
@device_smart
|
|
|
|
async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture):
|
|
|
|
"""Test is_cloud_connected property."""
|
|
|
|
assert isinstance(dev, SmartDevice)
|
|
|
|
assert "cloud_connect" in dev._components
|
|
|
|
|
|
|
|
is_connected = (
|
|
|
|
(cc := dev._last_update.get("get_connect_cloud_state"))
|
|
|
|
and not isinstance(cc, SmartErrorCode)
|
|
|
|
and cc["status"] == 0
|
|
|
|
)
|
|
|
|
|
|
|
|
assert dev.is_cloud_connected == is_connected
|
|
|
|
last_update = dev._last_update
|
|
|
|
|
2024-07-04 07:02:50 +00:00
|
|
|
for child in dev.children:
|
|
|
|
mocker.patch.object(child.protocol, "query", return_value=child._last_update)
|
|
|
|
|
2024-04-23 11:56:32 +00:00
|
|
|
last_update["get_connect_cloud_state"] = {"status": 0}
|
|
|
|
with patch.object(dev.protocol, "query", return_value=last_update):
|
|
|
|
await dev.update()
|
|
|
|
assert dev.is_cloud_connected is True
|
|
|
|
|
|
|
|
last_update["get_connect_cloud_state"] = {"status": 1}
|
|
|
|
with patch.object(dev.protocol, "query", return_value=last_update):
|
|
|
|
await dev.update()
|
|
|
|
assert dev.is_cloud_connected is False
|
|
|
|
|
|
|
|
last_update["get_connect_cloud_state"] = SmartErrorCode.UNKNOWN_METHOD_ERROR
|
|
|
|
with patch.object(dev.protocol, "query", return_value=last_update):
|
|
|
|
await dev.update()
|
|
|
|
assert dev.is_cloud_connected is False
|
|
|
|
|
|
|
|
# Test for no cloud_connect component during device initialisation
|
|
|
|
component_list = [
|
|
|
|
val
|
|
|
|
for val in dev._components_raw["component_list"]
|
|
|
|
if val["id"] not in {"cloud_connect"}
|
|
|
|
]
|
|
|
|
initial_response = {
|
|
|
|
"component_nego": {"component_list": component_list},
|
|
|
|
"get_connect_cloud_state": last_update["get_connect_cloud_state"],
|
|
|
|
"get_device_info": last_update["get_device_info"],
|
|
|
|
}
|
2024-07-04 07:02:50 +00:00
|
|
|
|
2024-04-23 11:56:32 +00:00
|
|
|
new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol)
|
2024-04-24 18:17:49 +00:00
|
|
|
|
|
|
|
first_call = True
|
|
|
|
|
2024-07-04 07:02:50 +00:00
|
|
|
async def side_effect_func(*args, **kwargs):
|
2024-04-24 18:17:49 +00:00
|
|
|
nonlocal first_call
|
2024-07-04 07:02:50 +00:00
|
|
|
resp = (
|
|
|
|
initial_response
|
|
|
|
if first_call
|
|
|
|
else await new_dev.protocol._query(*args, **kwargs)
|
|
|
|
)
|
2024-04-24 18:17:49 +00:00
|
|
|
first_call = False
|
|
|
|
return resp
|
|
|
|
|
2024-04-23 11:56:32 +00:00
|
|
|
with patch.object(
|
|
|
|
new_dev.protocol,
|
|
|
|
"query",
|
2024-04-24 18:17:49 +00:00
|
|
|
side_effect=side_effect_func,
|
2024-04-23 11:56:32 +00:00
|
|
|
):
|
|
|
|
await new_dev.update()
|
|
|
|
assert new_dev.is_cloud_connected is False
|