2023-09-13 13:46:38 +00:00
|
|
|
import inspect
|
2024-01-29 19:26:39 +00:00
|
|
|
import re
|
2020-05-27 14:55:18 +00:00
|
|
|
from datetime import datetime
|
2023-11-21 22:48:53 +00:00
|
|
|
from unittest.mock import Mock, patch
|
2020-05-27 14:55:18 +00:00
|
|
|
|
|
|
|
import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342
|
2024-01-29 19:26:39 +00:00
|
|
|
from voluptuous import (
|
|
|
|
REMOVE_EXTRA,
|
|
|
|
All,
|
|
|
|
Any,
|
|
|
|
Boolean,
|
|
|
|
In,
|
|
|
|
Invalid,
|
|
|
|
Optional,
|
|
|
|
Range,
|
|
|
|
Schema,
|
|
|
|
)
|
2020-05-27 14:55:18 +00:00
|
|
|
|
2023-09-13 13:46:38 +00:00
|
|
|
import kasa
|
2023-12-29 19:17:15 +00:00
|
|
|
from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException
|
2020-05-27 14:55:18 +00:00
|
|
|
|
2024-01-03 18:04:34 +00:00
|
|
|
from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on
|
2024-01-29 19:26:39 +00:00
|
|
|
from .fakeprotocol_iot import FakeIotProtocol
|
2020-05-27 14:55:18 +00:00
|
|
|
|
2023-09-13 13:46:38 +00:00
|
|
|
# List of all SmartXXX classes including the SmartDevice base class
|
|
|
|
smart_device_classes = [
|
|
|
|
dc
|
|
|
|
for (mn, dc) in inspect.getmembers(
|
|
|
|
kasa,
|
|
|
|
lambda member: inspect.isclass(member)
|
|
|
|
and (member == SmartDevice or issubclass(member, SmartDevice)),
|
|
|
|
)
|
|
|
|
]
|
|
|
|
|
2020-05-27 14:55:18 +00:00
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_state_info(dev):
|
|
|
|
assert isinstance(dev.state_information, dict)
|
|
|
|
|
|
|
|
|
2021-09-19 21:45:48 +00:00
|
|
|
@pytest.mark.requires_dummy
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_invalid_connection(dev):
|
2023-10-29 22:15:42 +00:00
|
|
|
with patch.object(
|
2024-01-29 19:26:39 +00:00
|
|
|
FakeIotProtocol, "query", side_effect=SmartDeviceException
|
2023-10-29 22:15:42 +00:00
|
|
|
), pytest.raises(SmartDeviceException):
|
|
|
|
await dev.update()
|
2021-09-19 21:43:17 +00:00
|
|
|
|
|
|
|
|
2024-01-03 18:04:34 +00:00
|
|
|
@has_emeter_iot
|
2021-09-19 21:43:17 +00:00
|
|
|
async def test_initial_update_emeter(dev, mocker):
|
|
|
|
"""Test that the initial update performs second query if emeter is available."""
|
|
|
|
dev._last_update = None
|
2023-10-07 19:18:47 +00:00
|
|
|
dev._features = set()
|
2021-09-19 21:43:17 +00:00
|
|
|
spy = mocker.spy(dev.protocol, "query")
|
|
|
|
await dev.update()
|
2023-09-14 18:51:40 +00:00
|
|
|
# Devices with small buffers may require 3 queries
|
|
|
|
expected_queries = 2 if dev.max_device_response_size > 4096 else 3
|
|
|
|
assert spy.call_count == expected_queries + len(dev.children)
|
2021-09-19 21:43:17 +00:00
|
|
|
|
|
|
|
|
2023-12-20 17:08:04 +00:00
|
|
|
@no_emeter_iot
|
2021-09-19 21:43:17 +00:00
|
|
|
async def test_initial_update_no_emeter(dev, mocker):
|
|
|
|
"""Test that the initial update performs second query if emeter is available."""
|
|
|
|
dev._last_update = None
|
2023-10-07 19:18:47 +00:00
|
|
|
dev._features = set()
|
2021-09-19 21:43:17 +00:00
|
|
|
spy = mocker.spy(dev.protocol, "query")
|
|
|
|
await dev.update()
|
2022-04-05 16:16:36 +00:00
|
|
|
# 2 calls are necessary as some devices crash on unexpected modules
|
|
|
|
# See #105, #120, #161
|
|
|
|
assert spy.call_count == 2
|
2020-05-27 14:55:18 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_query_helper(dev):
|
|
|
|
with pytest.raises(SmartDeviceException):
|
|
|
|
await dev._query_helper("test", "testcmd", {})
|
|
|
|
# TODO check for unwrapping?
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
@turn_on
|
|
|
|
async def test_state(dev, turn_on):
|
|
|
|
await handle_turn_on(dev, turn_on)
|
|
|
|
orig_state = dev.is_on
|
|
|
|
if orig_state:
|
|
|
|
await dev.turn_off()
|
2021-09-19 21:45:48 +00:00
|
|
|
await dev.update()
|
2020-05-27 14:55:18 +00:00
|
|
|
assert not dev.is_on
|
|
|
|
assert dev.is_off
|
|
|
|
|
|
|
|
await dev.turn_on()
|
2021-09-19 21:45:48 +00:00
|
|
|
await dev.update()
|
2020-05-27 14:55:18 +00:00
|
|
|
assert dev.is_on
|
|
|
|
assert not dev.is_off
|
|
|
|
else:
|
|
|
|
await dev.turn_on()
|
2021-09-19 21:45:48 +00:00
|
|
|
await dev.update()
|
2020-05-27 14:55:18 +00:00
|
|
|
assert dev.is_on
|
|
|
|
assert not dev.is_off
|
|
|
|
|
|
|
|
await dev.turn_off()
|
2021-09-19 21:45:48 +00:00
|
|
|
await dev.update()
|
2020-05-27 14:55:18 +00:00
|
|
|
assert not dev.is_on
|
|
|
|
assert dev.is_off
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_alias(dev):
|
|
|
|
test_alias = "TEST1234"
|
|
|
|
original = dev.alias
|
|
|
|
|
|
|
|
assert isinstance(original, str)
|
|
|
|
await dev.set_alias(test_alias)
|
2021-09-19 21:45:48 +00:00
|
|
|
await dev.update()
|
2020-05-27 14:55:18 +00:00
|
|
|
assert dev.alias == test_alias
|
|
|
|
|
|
|
|
await dev.set_alias(original)
|
2021-09-19 21:45:48 +00:00
|
|
|
await dev.update()
|
2020-05-27 14:55:18 +00:00
|
|
|
assert dev.alias == original
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
@turn_on
|
|
|
|
async def test_on_since(dev, turn_on):
|
|
|
|
await handle_turn_on(dev, turn_on)
|
|
|
|
orig_state = dev.is_on
|
|
|
|
if "on_time" not in dev.sys_info and not dev.is_strip:
|
|
|
|
assert dev.on_since is None
|
|
|
|
elif orig_state:
|
|
|
|
assert isinstance(dev.on_since, datetime)
|
|
|
|
else:
|
|
|
|
assert dev.on_since is None
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_time(dev):
|
|
|
|
assert isinstance(await dev.get_time(), datetime)
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_timezone(dev):
|
|
|
|
TZ_SCHEMA(await dev.get_timezone())
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_hw_info(dev):
|
2024-01-29 19:26:39 +00:00
|
|
|
SYSINFO_SCHEMA(dev.hw_info)
|
2020-05-27 14:55:18 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_location(dev):
|
2024-01-29 19:26:39 +00:00
|
|
|
SYSINFO_SCHEMA(dev.location)
|
2020-05-27 14:55:18 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_rssi(dev):
|
2024-01-29 19:26:39 +00:00
|
|
|
SYSINFO_SCHEMA({"rssi": dev.rssi}) # wrapping for vol
|
2020-05-27 14:55:18 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_mac(dev):
|
2024-01-29 19:26:39 +00:00
|
|
|
SYSINFO_SCHEMA({"mac": dev.mac}) # wrapping for val
|
2020-05-27 14:55:18 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_representation(dev):
|
|
|
|
import re
|
|
|
|
|
|
|
|
pattern = re.compile("<.* model .* at .* (.*), is_on: .* - dev specific: .*>")
|
|
|
|
assert pattern.match(str(dev))
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2020-05-27 14:55:18 +00:00
|
|
|
async def test_childrens(dev):
|
|
|
|
"""Make sure that children property is exposed by every device."""
|
|
|
|
if dev.is_strip:
|
|
|
|
assert len(dev.children) > 0
|
|
|
|
else:
|
|
|
|
assert len(dev.children) == 0
|
2022-02-07 08:13:47 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2023-11-21 20:58:41 +00:00
|
|
|
async def test_children(dev):
|
|
|
|
"""Make sure that children property is exposed by every device."""
|
|
|
|
if dev.is_strip:
|
|
|
|
assert len(dev.children) > 0
|
|
|
|
assert dev.has_children is True
|
|
|
|
else:
|
|
|
|
assert len(dev.children) == 0
|
|
|
|
assert dev.has_children is False
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2022-02-07 08:13:47 +00:00
|
|
|
async def test_internal_state(dev):
|
|
|
|
"""Make sure the internal state returns the last update results."""
|
|
|
|
assert dev.internal_state == dev._last_update
|
2022-03-02 15:29:20 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2022-03-02 15:29:20 +00:00
|
|
|
async def test_features(dev):
|
|
|
|
"""Make sure features is always accessible."""
|
|
|
|
sysinfo = dev._last_update["system"]["get_sysinfo"]
|
|
|
|
if "feature" in sysinfo:
|
|
|
|
assert dev.features == set(sysinfo["feature"].split(":"))
|
|
|
|
else:
|
|
|
|
assert dev.features == set()
|
2023-09-13 13:46:38 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2023-09-14 18:51:40 +00:00
|
|
|
async def test_max_device_response_size(dev):
|
|
|
|
"""Make sure every device return has a set max response size."""
|
|
|
|
assert dev.max_device_response_size > 0
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2023-09-14 18:51:40 +00:00
|
|
|
async def test_estimated_response_sizes(dev):
|
|
|
|
"""Make sure every module has an estimated response size set."""
|
|
|
|
for mod in dev.modules.values():
|
|
|
|
assert mod.estimated_query_response_size > 0
|
|
|
|
|
|
|
|
|
2023-09-13 13:46:38 +00:00
|
|
|
@pytest.mark.parametrize("device_class", smart_device_classes)
|
|
|
|
def test_device_class_ctors(device_class):
|
|
|
|
"""Make sure constructor api not broken for new and existing SmartDevices."""
|
|
|
|
host = "127.0.0.2"
|
|
|
|
port = 1234
|
|
|
|
credentials = Credentials("foo", "bar")
|
2023-12-29 19:17:15 +00:00
|
|
|
config = DeviceConfig(host, port_override=port, credentials=credentials)
|
|
|
|
dev = device_class(host, config=config)
|
2023-09-13 13:46:38 +00:00
|
|
|
assert dev.host == host
|
|
|
|
assert dev.port == port
|
|
|
|
assert dev.credentials == credentials
|
2023-10-05 20:50:54 +00:00
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2023-10-05 20:50:54 +00:00
|
|
|
async def test_modules_preserved(dev: SmartDevice):
|
|
|
|
"""Make modules that are not being updated are preserved between updates."""
|
|
|
|
dev._last_update["some_module_not_being_updated"] = "should_be_kept"
|
|
|
|
await dev.update()
|
|
|
|
assert dev._last_update["some_module_not_being_updated"] == "should_be_kept"
|
2023-10-07 18:58:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
async def test_create_smart_device_with_timeout():
|
|
|
|
"""Make sure timeout is passed to the protocol."""
|
2023-12-29 19:17:15 +00:00
|
|
|
host = "127.0.0.1"
|
|
|
|
dev = SmartDevice(host, config=DeviceConfig(host, timeout=100))
|
2023-12-19 14:11:59 +00:00
|
|
|
assert dev.protocol._transport._timeout == 100
|
2023-11-21 20:58:41 +00:00
|
|
|
|
|
|
|
|
2023-11-21 22:48:53 +00:00
|
|
|
async def test_create_thin_wrapper():
|
|
|
|
"""Make sure thin wrapper is created with the correct device type."""
|
|
|
|
mock = Mock()
|
2023-12-29 19:17:15 +00:00
|
|
|
config = DeviceConfig(
|
|
|
|
host="test_host",
|
|
|
|
port_override=1234,
|
|
|
|
timeout=100,
|
|
|
|
credentials=Credentials("username", "password"),
|
|
|
|
)
|
2023-11-21 22:48:53 +00:00
|
|
|
with patch("kasa.device_factory.connect", return_value=mock) as connect:
|
2023-12-29 19:17:15 +00:00
|
|
|
dev = await SmartDevice.connect(config=config)
|
2023-11-21 22:48:53 +00:00
|
|
|
assert dev is mock
|
|
|
|
|
|
|
|
connect.assert_called_once_with(
|
2023-12-29 19:17:15 +00:00
|
|
|
host=None,
|
|
|
|
config=config,
|
2023-11-21 22:48:53 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
2023-12-04 18:50:05 +00:00
|
|
|
@device_iot
|
2023-11-21 20:58:41 +00:00
|
|
|
async def test_modules_not_supported(dev: SmartDevice):
|
|
|
|
"""Test that unsupported modules do not break the device."""
|
|
|
|
for module in dev.modules.values():
|
|
|
|
assert module.is_supported is not None
|
|
|
|
await dev.update()
|
|
|
|
for module in dev.modules.values():
|
|
|
|
assert module.is_supported is not None
|
2024-01-29 19:26:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
def check_mac(x):
|
|
|
|
if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()):
|
|
|
|
return x
|
|
|
|
raise Invalid(x)
|
|
|
|
|
|
|
|
|
|
|
|
TZ_SCHEMA = Schema(
|
|
|
|
{"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str}
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
SYSINFO_SCHEMA = Schema(
|
|
|
|
{
|
|
|
|
"active_mode": In(["schedule", "none", "count_down"]),
|
|
|
|
"alias": str,
|
|
|
|
"dev_name": str,
|
|
|
|
"deviceId": str,
|
|
|
|
"feature": str,
|
|
|
|
"fwId": str,
|
|
|
|
"hwId": str,
|
|
|
|
"hw_ver": str,
|
|
|
|
"icon_hash": str,
|
|
|
|
"led_off": Boolean,
|
|
|
|
"latitude": Any(All(float, Range(min=-90, max=90)), 0, None),
|
|
|
|
"latitude_i": Any(
|
|
|
|
All(int, Range(min=-900000, max=900000)),
|
|
|
|
All(float, Range(min=-900000, max=900000)),
|
|
|
|
0,
|
|
|
|
None,
|
|
|
|
),
|
|
|
|
"longitude": Any(All(float, Range(min=-180, max=180)), 0, None),
|
|
|
|
"longitude_i": Any(
|
|
|
|
All(int, Range(min=-18000000, max=18000000)),
|
|
|
|
All(float, Range(min=-18000000, max=18000000)),
|
|
|
|
0,
|
|
|
|
None,
|
|
|
|
),
|
|
|
|
"mac": check_mac,
|
|
|
|
"model": str,
|
|
|
|
"oemId": str,
|
|
|
|
"on_time": int,
|
|
|
|
"relay_state": int,
|
|
|
|
"rssi": Any(int, None), # rssi can also be positive, see #54
|
|
|
|
"sw_ver": str,
|
|
|
|
"type": str,
|
|
|
|
"mic_type": str,
|
|
|
|
"updating": Boolean,
|
|
|
|
# these are available on hs220
|
|
|
|
"brightness": int,
|
|
|
|
"preferred_state": [
|
|
|
|
{"brightness": All(int, Range(min=0, max=100)), "index": int}
|
|
|
|
],
|
|
|
|
"next_action": {"type": int},
|
|
|
|
"child_num": Optional(Any(None, int)),
|
|
|
|
"children": Optional(list),
|
|
|
|
},
|
|
|
|
extra=REMOVE_EXTRA,
|
|
|
|
)
|