mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-08-09 20:24:02 +00:00
Follow main package structure for tests (#1317)
* Transport tests under tests/transports/ * Protocol tests under tests/protocols/ * IOT tests under iot/ * Plus some minor cleanups, most code changes are related to splitting up smart & iot tests
This commit is contained in:
201
tests/iot/modules/test_emeter.py
Normal file
201
tests/iot/modules/test_emeter.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from voluptuous import (
|
||||
All,
|
||||
Any,
|
||||
Coerce,
|
||||
Range,
|
||||
Schema,
|
||||
)
|
||||
|
||||
from kasa import Device, DeviceType, EmeterStatus, Module
|
||||
from kasa.interfaces.energy import Energy
|
||||
from kasa.iot import IotStrip
|
||||
from kasa.iot.modules.emeter import Emeter
|
||||
from tests.conftest import has_emeter_iot, no_emeter_iot
|
||||
|
||||
CURRENT_CONSUMPTION_SCHEMA = Schema(
|
||||
Any(
|
||||
{
|
||||
"voltage_mv": Any(All(float, Range(min=0, max=300000)), int, None),
|
||||
"power_mw": Any(Coerce(float), None),
|
||||
"current_ma": Any(All(float), int, None),
|
||||
"energy_wh": Any(Coerce(float), None),
|
||||
"total_wh": Any(Coerce(float), None),
|
||||
"voltage": Any(All(float, Range(min=0, max=300)), None),
|
||||
"power": Any(Coerce(float), None),
|
||||
"current": Any(All(float), None),
|
||||
"total": Any(Coerce(float), None),
|
||||
"energy": Any(Coerce(float), None),
|
||||
"slot_id": Any(Coerce(int), None),
|
||||
},
|
||||
None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@no_emeter_iot
|
||||
async def test_no_emeter(dev):
|
||||
assert not dev.has_emeter
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
await dev.get_emeter_realtime()
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
await dev.get_emeter_daily()
|
||||
with pytest.raises(AttributeError):
|
||||
await dev.get_emeter_monthly()
|
||||
with pytest.raises(AttributeError):
|
||||
await dev.erase_emeter_stats()
|
||||
|
||||
|
||||
@has_emeter_iot
|
||||
async def test_get_emeter_realtime(dev):
|
||||
emeter = dev.modules[Module.Energy]
|
||||
|
||||
current_emeter = await emeter.get_status()
|
||||
# Check realtime query gets the same value as status property
|
||||
# iot _query_helper strips out the error code from module responses.
|
||||
# but it's not stripped out of the _modular_update queries.
|
||||
assert current_emeter == {k: v for k, v in emeter.status.items() if k != "err_code"}
|
||||
CURRENT_CONSUMPTION_SCHEMA(current_emeter)
|
||||
|
||||
|
||||
@has_emeter_iot
|
||||
@pytest.mark.requires_dummy
|
||||
async def test_get_emeter_daily(dev):
|
||||
emeter = dev.modules[Module.Energy]
|
||||
|
||||
assert await emeter.get_daily_stats(year=1900, month=1) == {}
|
||||
|
||||
d = await emeter.get_daily_stats()
|
||||
assert len(d) > 0
|
||||
|
||||
k, v = d.popitem()
|
||||
assert isinstance(k, int)
|
||||
assert isinstance(v, float)
|
||||
|
||||
# Test kwh (energy, energy_wh)
|
||||
d = await emeter.get_daily_stats(kwh=False)
|
||||
k2, v2 = d.popitem()
|
||||
assert v * 1000 == v2
|
||||
|
||||
|
||||
@has_emeter_iot
|
||||
@pytest.mark.requires_dummy
|
||||
async def test_get_emeter_monthly(dev):
|
||||
emeter = dev.modules[Module.Energy]
|
||||
|
||||
assert await emeter.get_monthly_stats(year=1900) == {}
|
||||
|
||||
d = await emeter.get_monthly_stats()
|
||||
assert len(d) > 0
|
||||
|
||||
k, v = d.popitem()
|
||||
assert isinstance(k, int)
|
||||
assert isinstance(v, float)
|
||||
|
||||
# Test kwh (energy, energy_wh)
|
||||
d = await emeter.get_monthly_stats(kwh=False)
|
||||
k2, v2 = d.popitem()
|
||||
assert v * 1000 == v2
|
||||
|
||||
|
||||
@has_emeter_iot
|
||||
async def test_emeter_status(dev):
|
||||
emeter = dev.modules[Module.Energy]
|
||||
|
||||
d = await emeter.get_status()
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
assert d["foo"]
|
||||
|
||||
assert d["power_mw"] == d["power"] * 1000
|
||||
# bulbs have only power according to tplink simulator.
|
||||
if (
|
||||
dev.device_type is not DeviceType.Bulb
|
||||
and dev.device_type is not DeviceType.LightStrip
|
||||
):
|
||||
assert d["voltage_mv"] == d["voltage"] * 1000
|
||||
|
||||
assert d["current_ma"] == d["current"] * 1000
|
||||
assert d["total_wh"] == d["total"] * 1000
|
||||
|
||||
|
||||
@pytest.mark.skip("not clearing your stats..")
|
||||
@has_emeter_iot
|
||||
async def test_erase_emeter_stats(dev):
|
||||
emeter = dev.modules[Module.Energy]
|
||||
|
||||
await emeter.erase_emeter()
|
||||
|
||||
|
||||
@has_emeter_iot
|
||||
async def test_current_consumption(dev):
|
||||
emeter = dev.modules[Module.Energy]
|
||||
x = emeter.current_consumption
|
||||
assert isinstance(x, float)
|
||||
assert x >= 0.0
|
||||
|
||||
|
||||
async def test_emeterstatus_missing_current():
|
||||
"""KL125 does not report 'current' for emeter."""
|
||||
regular = EmeterStatus(
|
||||
{"err_code": 0, "power_mw": 0, "total_wh": 13, "current_ma": 123}
|
||||
)
|
||||
assert regular["current"] == 0.123
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
regular["invalid_key"]
|
||||
|
||||
missing_current = EmeterStatus({"err_code": 0, "power_mw": 0, "total_wh": 13})
|
||||
assert missing_current["current"] is None
|
||||
|
||||
|
||||
async def test_emeter_daily():
|
||||
"""Test fetching the emeter for today.
|
||||
|
||||
This test uses inline data since the fixtures
|
||||
will not have data for the current day.
|
||||
"""
|
||||
emeter_data = {
|
||||
"get_daystat": {
|
||||
"day_list": [{"day": 1, "energy_wh": 8, "month": 1, "year": 2023}],
|
||||
"err_code": 0,
|
||||
}
|
||||
}
|
||||
|
||||
class MockEmeter(Emeter):
|
||||
@property
|
||||
def data(self):
|
||||
return emeter_data
|
||||
|
||||
emeter = MockEmeter(Mock(), "emeter")
|
||||
now = datetime.datetime.now()
|
||||
emeter_data["get_daystat"]["day_list"].append(
|
||||
{"day": now.day, "energy_wh": 500, "month": now.month, "year": now.year}
|
||||
)
|
||||
assert emeter.consumption_today == 0.500
|
||||
|
||||
|
||||
@has_emeter_iot
|
||||
async def test_supported(dev: Device):
|
||||
energy_module = dev.modules.get(Module.Energy)
|
||||
assert energy_module
|
||||
|
||||
info = (
|
||||
dev._last_update
|
||||
if not isinstance(dev, IotStrip)
|
||||
else dev.children[0].internal_state
|
||||
)
|
||||
emeter = info[energy_module._module]["get_realtime"]
|
||||
has_total = "total" in emeter or "total_wh" in emeter
|
||||
has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter
|
||||
assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total
|
||||
assert (
|
||||
energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT)
|
||||
is has_voltage_current
|
||||
)
|
||||
assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True
|
86
tests/iot/modules/test_usage.py
Normal file
86
tests/iot/modules/test_usage.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
from kasa.iot.modules import Usage
|
||||
|
||||
|
||||
def test_usage_convert_stat_data():
|
||||
usage = Usage(None, module="usage")
|
||||
|
||||
test_data = []
|
||||
assert usage._convert_stat_data(test_data, "day") == {}
|
||||
|
||||
test_data = [
|
||||
{"year": 2016, "month": 5, "day": 2, "time": 20},
|
||||
{"year": 2016, "month": 5, "day": 4, "time": 30},
|
||||
]
|
||||
d = usage._convert_stat_data(test_data, "day")
|
||||
assert len(d) == len(test_data)
|
||||
assert isinstance(d, dict)
|
||||
k, v = d.popitem()
|
||||
assert isinstance(k, int)
|
||||
assert isinstance(v, int)
|
||||
assert k == 4
|
||||
assert v == 30
|
||||
|
||||
|
||||
def test_usage_today():
|
||||
"""Test fetching the usage for today.
|
||||
|
||||
This test uses inline data since the fixtures
|
||||
will not have data for the current day.
|
||||
"""
|
||||
emeter_data = {
|
||||
"get_daystat": {
|
||||
"day_list": [],
|
||||
"err_code": 0,
|
||||
}
|
||||
}
|
||||
|
||||
class MockUsage(Usage):
|
||||
@property
|
||||
def data(self):
|
||||
return emeter_data
|
||||
|
||||
usage = MockUsage(Mock(), "usage")
|
||||
assert usage.usage_today is None
|
||||
now = datetime.datetime.now()
|
||||
emeter_data["get_daystat"]["day_list"].extend(
|
||||
[
|
||||
{"day": now.day - 1, "time": 200, "month": now.month - 1, "year": now.year},
|
||||
{"day": now.day, "time": 500, "month": now.month, "year": now.year},
|
||||
{"day": now.day + 1, "time": 100, "month": now.month + 1, "year": now.year},
|
||||
]
|
||||
)
|
||||
assert usage.usage_today == 500
|
||||
|
||||
|
||||
def test_usage_this_month():
|
||||
"""Test fetching the usage for this month.
|
||||
|
||||
This test uses inline data since the fixtures
|
||||
will not have data for the current month.
|
||||
"""
|
||||
emeter_data = {
|
||||
"get_monthstat": {
|
||||
"month_list": [],
|
||||
"err_code": 0,
|
||||
}
|
||||
}
|
||||
|
||||
class MockUsage(Usage):
|
||||
@property
|
||||
def data(self):
|
||||
return emeter_data
|
||||
|
||||
usage = MockUsage(Mock(), "usage")
|
||||
assert usage.usage_this_month is None
|
||||
now = datetime.datetime.now()
|
||||
emeter_data["get_monthstat"]["month_list"].extend(
|
||||
[
|
||||
{"time": 200, "month": now.month - 1, "year": now.year},
|
||||
{"time": 500, "month": now.month, "year": now.year},
|
||||
{"time": 100, "month": now.month + 1, "year": now.year},
|
||||
]
|
||||
)
|
||||
assert usage.usage_this_month == 500
|
320
tests/iot/test_iotbulb.py
Normal file
320
tests/iot/test_iotbulb.py
Normal file
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from voluptuous import (
|
||||
All,
|
||||
Boolean,
|
||||
Optional,
|
||||
Range,
|
||||
Schema,
|
||||
)
|
||||
|
||||
from kasa import Device, IotLightPreset, KasaException, LightState, Module
|
||||
from kasa.iot import IotBulb, IotDimmer
|
||||
from kasa.iot.modules import LightPreset as IotLightPresetModule
|
||||
from tests.conftest import (
|
||||
bulb_iot,
|
||||
color_bulb_iot,
|
||||
dimmable_iot,
|
||||
handle_turn_on,
|
||||
non_dimmable_iot,
|
||||
turn_on,
|
||||
variable_temp_iot,
|
||||
)
|
||||
from tests.iot.test_iotdevice import SYSINFO_SCHEMA
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_bulb_sysinfo(dev: Device):
|
||||
assert dev.sys_info is not None
|
||||
SYSINFO_SCHEMA_BULB(dev.sys_info)
|
||||
|
||||
assert dev.model is not None
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_light_state_without_update(dev: IotBulb, monkeypatch):
|
||||
monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None)
|
||||
with pytest.raises(KasaException):
|
||||
print(dev.light_state)
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_get_light_state(dev: IotBulb):
|
||||
LIGHT_STATE_SCHEMA(await dev.get_light_state())
|
||||
|
||||
|
||||
@color_bulb_iot
|
||||
async def test_set_hsv_transition(dev: IotBulb, mocker):
|
||||
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await light.set_hsv(10, 10, 100, transition=1000)
|
||||
|
||||
set_light_state.assert_called_with(
|
||||
{"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0},
|
||||
transition=1000,
|
||||
)
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_light_set_state(dev: IotBulb, mocker):
|
||||
"""Testing setting LightState on the light module."""
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
set_light_state = mocker.spy(dev, "_set_light_state")
|
||||
state = LightState(light_on=True)
|
||||
await light.set_state(state)
|
||||
|
||||
set_light_state.assert_called_with({"on_off": 1}, transition=None)
|
||||
state = LightState(light_on=False)
|
||||
await light.set_state(state)
|
||||
|
||||
set_light_state.assert_called_with({"on_off": 0}, transition=None)
|
||||
|
||||
|
||||
@variable_temp_iot
|
||||
async def test_set_color_temp_transition(dev: IotBulb, mocker):
|
||||
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await light.set_color_temp(2700, transition=100)
|
||||
|
||||
set_light_state.assert_called_with({"color_temp": 2700}, transition=100)
|
||||
|
||||
|
||||
@variable_temp_iot
|
||||
@pytest.mark.xdist_group(name="caplog")
|
||||
async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
|
||||
monkeypatch.setitem(dev._sys_info, "model", "unknown bulb")
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
assert light.valid_temperature_range == (2700, 5000)
|
||||
assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text
|
||||
|
||||
|
||||
@dimmable_iot
|
||||
@turn_on
|
||||
async def test_dimmable_brightness(dev: IotBulb, turn_on):
|
||||
assert isinstance(dev, IotBulb | IotDimmer)
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
assert dev._is_dimmable
|
||||
|
||||
await light.set_brightness(50)
|
||||
await dev.update()
|
||||
assert light.brightness == 50
|
||||
|
||||
await light.set_brightness(10)
|
||||
await dev.update()
|
||||
assert light.brightness == 10
|
||||
|
||||
with pytest.raises(TypeError, match="Brightness must be an integer"):
|
||||
await light.set_brightness("foo") # type: ignore[arg-type]
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_turn_on_transition(dev: IotBulb, mocker):
|
||||
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
|
||||
await dev.turn_on(transition=1000)
|
||||
|
||||
set_light_state.assert_called_with({"on_off": 1}, transition=1000)
|
||||
|
||||
await dev.turn_off(transition=100)
|
||||
|
||||
set_light_state.assert_called_with({"on_off": 0}, transition=100)
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
|
||||
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await light.set_brightness(10, transition=1000)
|
||||
|
||||
set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000)
|
||||
|
||||
|
||||
@dimmable_iot
|
||||
async def test_invalid_brightness(dev: IotBulb):
|
||||
assert dev._is_dimmable
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"),
|
||||
):
|
||||
await light.set_brightness(110)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"),
|
||||
):
|
||||
await light.set_brightness(-100)
|
||||
|
||||
|
||||
@non_dimmable_iot
|
||||
async def test_non_dimmable(dev: IotBulb):
|
||||
assert not dev._is_dimmable
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
with pytest.raises(KasaException):
|
||||
assert light.brightness == 0
|
||||
with pytest.raises(KasaException):
|
||||
await light.set_brightness(100)
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_ignore_default_not_set_without_color_mode_change_turn_on(
|
||||
dev: IotBulb, mocker
|
||||
):
|
||||
query_helper = mocker.patch("kasa.iot.IotBulb._query_helper")
|
||||
# When turning back without settings, ignore default to restore the state
|
||||
await dev.turn_on()
|
||||
args, kwargs = query_helper.call_args_list[0]
|
||||
assert args[2] == {"on_off": 1, "ignore_default": 0}
|
||||
|
||||
await dev.turn_off()
|
||||
args, kwargs = query_helper.call_args_list[1]
|
||||
assert args[2] == {"on_off": 0, "ignore_default": 1}
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_list_presets(dev: IotBulb):
|
||||
light_preset = dev.modules.get(Module.LightPreset)
|
||||
assert light_preset
|
||||
assert isinstance(light_preset, IotLightPresetModule)
|
||||
presets = light_preset._deprecated_presets
|
||||
# Light strip devices may list some light effects along with normal presets but these
|
||||
# are handled by the LightEffect module so exclude preferred states with id
|
||||
raw_presets = [
|
||||
pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate
|
||||
]
|
||||
assert len(presets) == len(raw_presets)
|
||||
|
||||
for preset, raw in zip(presets, raw_presets, strict=False):
|
||||
assert preset.index == raw["index"]
|
||||
assert preset.brightness == raw["brightness"]
|
||||
assert preset.hue == raw["hue"]
|
||||
assert preset.saturation == raw["saturation"]
|
||||
assert preset.color_temp == raw["color_temp"]
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_modify_preset(dev: IotBulb, mocker):
|
||||
"""Verify that modifying preset calls the and exceptions are raised properly."""
|
||||
if (
|
||||
not (light_preset := dev.modules.get(Module.LightPreset))
|
||||
or not light_preset._deprecated_presets
|
||||
):
|
||||
pytest.skip("Some strips do not support presets")
|
||||
|
||||
assert isinstance(light_preset, IotLightPresetModule)
|
||||
data: dict[str, int | None] = {
|
||||
"index": 0,
|
||||
"brightness": 10,
|
||||
"hue": 0,
|
||||
"saturation": 0,
|
||||
"color_temp": 0,
|
||||
}
|
||||
preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type]
|
||||
|
||||
assert preset.index == 0
|
||||
assert preset.brightness == 10
|
||||
assert preset.hue == 0
|
||||
assert preset.saturation == 0
|
||||
assert preset.color_temp == 0
|
||||
|
||||
await light_preset._deprecated_save_preset(preset)
|
||||
await dev.update()
|
||||
assert light_preset._deprecated_presets[0].brightness == 10
|
||||
|
||||
with pytest.raises(KasaException):
|
||||
await light_preset._deprecated_save_preset(
|
||||
IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
|
||||
@bulb_iot
|
||||
@pytest.mark.parametrize(
|
||||
("preset", "payload"),
|
||||
[
|
||||
(
|
||||
IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg]
|
||||
{"index": 0, "hue": 0, "brightness": 1, "saturation": 0},
|
||||
),
|
||||
(
|
||||
IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg]
|
||||
{"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker):
|
||||
"""Test that modify preset payloads ignore none values."""
|
||||
if (
|
||||
not (light_preset := dev.modules.get(Module.LightPreset))
|
||||
or not light_preset._deprecated_presets
|
||||
):
|
||||
pytest.skip("Some strips do not support presets")
|
||||
|
||||
query_helper = mocker.patch("kasa.iot.IotBulb._query_helper")
|
||||
await light_preset._deprecated_save_preset(preset)
|
||||
query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload)
|
||||
|
||||
|
||||
LIGHT_STATE_SCHEMA = Schema(
|
||||
{
|
||||
"brightness": All(int, Range(min=0, max=100)),
|
||||
"color_temp": int,
|
||||
"hue": All(int, Range(min=0, max=360)),
|
||||
"mode": str,
|
||||
"on_off": Boolean,
|
||||
"saturation": All(int, Range(min=0, max=100)),
|
||||
"length": Optional(int),
|
||||
"transition": Optional(int),
|
||||
"dft_on_state": Optional(
|
||||
{
|
||||
"brightness": All(int, Range(min=0, max=100)),
|
||||
"color_temp": All(int, Range(min=0, max=9000)),
|
||||
"hue": All(int, Range(min=0, max=360)),
|
||||
"mode": str,
|
||||
"saturation": All(int, Range(min=0, max=100)),
|
||||
"groups": Optional(list[int]),
|
||||
}
|
||||
),
|
||||
"err_code": int,
|
||||
}
|
||||
)
|
||||
|
||||
SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend(
|
||||
{
|
||||
"ctrl_protocols": Optional(dict),
|
||||
"description": Optional(str), # Seen on LBxxx, similar to dev_name
|
||||
"dev_state": str,
|
||||
"disco_ver": str,
|
||||
"heapsize": int,
|
||||
"is_color": Boolean,
|
||||
"is_dimmable": Boolean,
|
||||
"is_factory": Boolean,
|
||||
"is_variable_color_temp": Boolean,
|
||||
"light_state": LIGHT_STATE_SCHEMA,
|
||||
"preferred_state": [
|
||||
{
|
||||
"brightness": All(int, Range(min=0, max=100)),
|
||||
"color_temp": int,
|
||||
"hue": All(int, Range(min=0, max=360)),
|
||||
"index": int,
|
||||
"saturation": All(int, Range(min=0, max=100)),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bulb_iot
|
||||
async def test_turn_on_behaviours(dev: IotBulb):
|
||||
behavior = await dev.get_turn_on_behavior()
|
||||
assert behavior
|
310
tests/iot/test_iotdevice.py
Normal file
310
tests/iot/test_iotdevice.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""Module for common iotdevice tests."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from voluptuous import (
|
||||
REMOVE_EXTRA,
|
||||
All,
|
||||
Any,
|
||||
Boolean,
|
||||
In,
|
||||
Invalid,
|
||||
Optional,
|
||||
Range,
|
||||
Schema,
|
||||
)
|
||||
|
||||
from kasa import DeviceType, KasaException, Module
|
||||
from kasa.iot import IotDevice
|
||||
from kasa.iot.iotmodule import _merge_dict
|
||||
from tests.conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on
|
||||
from tests.device_fixtures import device_iot, has_emeter_iot, no_emeter_iot
|
||||
from tests.fakeprotocol_iot import FakeIotProtocol
|
||||
|
||||
TZ_SCHEMA = Schema(
|
||||
{"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str}
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_state_info(dev):
|
||||
assert isinstance(dev.state_information, dict)
|
||||
|
||||
|
||||
@pytest.mark.requires_dummy
|
||||
@device_iot
|
||||
async def test_invalid_connection(mocker, dev):
|
||||
mocker.patch.object(FakeIotProtocol, "query", side_effect=KasaException)
|
||||
with pytest.raises(KasaException):
|
||||
await dev.update()
|
||||
|
||||
|
||||
@has_emeter_iot
|
||||
async def test_initial_update_emeter(dev, mocker):
|
||||
"""Test that the initial update performs second query if emeter is available."""
|
||||
dev._last_update = None
|
||||
dev._legacy_features = set()
|
||||
spy = mocker.spy(dev.protocol, "query")
|
||||
await dev.update()
|
||||
# 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)
|
||||
|
||||
|
||||
@no_emeter_iot
|
||||
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
|
||||
dev._legacy_features = set()
|
||||
spy = mocker.spy(dev.protocol, "query")
|
||||
await dev.update()
|
||||
# child calls will happen if a child has a module with a query (e.g. schedule)
|
||||
child_calls = 0
|
||||
for child in dev.children:
|
||||
for module in child.modules.values():
|
||||
if module.query():
|
||||
child_calls += 1
|
||||
break
|
||||
# 2 parent are necessary as some devices crash on unexpected modules
|
||||
# See #105, #120, #161
|
||||
assert spy.call_count == 2 + child_calls
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_query_helper(dev):
|
||||
with pytest.raises(KasaException):
|
||||
await dev._query_helper("test", "testcmd", {})
|
||||
# TODO check for unwrapping?
|
||||
|
||||
|
||||
@device_iot
|
||||
@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()
|
||||
await dev.update()
|
||||
assert not dev.is_on
|
||||
assert dev.is_off
|
||||
|
||||
await dev.turn_on()
|
||||
await dev.update()
|
||||
assert dev.is_on
|
||||
assert not dev.is_off
|
||||
else:
|
||||
await dev.turn_on()
|
||||
await dev.update()
|
||||
assert dev.is_on
|
||||
assert not dev.is_off
|
||||
|
||||
await dev.turn_off()
|
||||
await dev.update()
|
||||
assert not dev.is_on
|
||||
assert dev.is_off
|
||||
|
||||
|
||||
@device_iot
|
||||
@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 dev.device_type is not DeviceType.Strip:
|
||||
assert dev.on_since is None
|
||||
elif orig_state:
|
||||
assert isinstance(dev.on_since, datetime)
|
||||
else:
|
||||
assert dev.on_since is None
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_time(dev):
|
||||
assert isinstance(dev.modules[Module.Time].time, datetime)
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_timezone(dev):
|
||||
TZ_SCHEMA(await dev.modules[Module.Time].get_timezone())
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_hw_info(dev):
|
||||
SYSINFO_SCHEMA(dev.hw_info)
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_location(dev):
|
||||
SYSINFO_SCHEMA(dev.location)
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_rssi(dev):
|
||||
SYSINFO_SCHEMA({"rssi": dev.rssi}) # wrapping for vol
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_mac(dev):
|
||||
SYSINFO_SCHEMA({"mac": dev.mac}) # wrapping for val
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_representation(dev):
|
||||
pattern = re.compile(r"<DeviceType\..+ at .+? - .*? \(.+?\)>")
|
||||
assert pattern.match(str(dev))
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_children(dev):
|
||||
"""Make sure that children property is exposed by every device."""
|
||||
if dev.device_type is DeviceType.Strip:
|
||||
assert len(dev.children) > 0
|
||||
else:
|
||||
assert len(dev.children) == 0
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_modules_preserved(dev: IotDevice):
|
||||
"""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"
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_internal_state(dev):
|
||||
"""Make sure the internal state returns the last update results."""
|
||||
assert dev.internal_state == dev._last_update
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_features(dev):
|
||||
"""Make sure features is always accessible."""
|
||||
sysinfo = dev._last_update["system"]["get_sysinfo"]
|
||||
if "feature" in sysinfo:
|
||||
assert dev._legacy_features == set(sysinfo["feature"].split(":"))
|
||||
else:
|
||||
assert dev._legacy_features == set()
|
||||
|
||||
|
||||
@device_iot
|
||||
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
|
||||
|
||||
|
||||
@device_iot
|
||||
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
|
||||
|
||||
|
||||
@device_iot
|
||||
async def test_modules_not_supported(dev: IotDevice):
|
||||
"""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
|
||||
|
||||
|
||||
async def test_get_modules():
|
||||
"""Test getting modules for child and parent modules."""
|
||||
dummy_device = await get_device_for_fixture_protocol(
|
||||
"HS100(US)_2.0_1.5.6.json", "IOT"
|
||||
)
|
||||
from kasa.iot.modules import Cloud
|
||||
|
||||
# Modules on device
|
||||
module = dummy_device.modules.get("cloud")
|
||||
assert module
|
||||
assert module._device == dummy_device
|
||||
assert isinstance(module, Cloud)
|
||||
|
||||
module = dummy_device.modules.get(Module.IotCloud)
|
||||
assert module
|
||||
assert module._device == dummy_device
|
||||
assert isinstance(module, Cloud)
|
||||
|
||||
# Invalid modules
|
||||
module = dummy_device.modules.get("DummyModule")
|
||||
assert module is None
|
||||
|
||||
module = dummy_device.modules.get(Module.Cloud)
|
||||
assert module is None
|
||||
|
||||
|
||||
def test_merge_dict():
|
||||
"""Test the recursive dict merge."""
|
||||
dest = {"a": 1, "b": {"c": 2, "d": 3}}
|
||||
source = {"b": {"c": 4, "e": 5}}
|
||||
assert _merge_dict(dest, source) == {"a": 1, "b": {"c": 4, "d": 3, "e": 5}}
|
||||
|
||||
dest = {"smartlife.iot.common.emeter": {"get_realtime": None}}
|
||||
source = {
|
||||
"smartlife.iot.common.emeter": {"get_daystat": {"month": 8, "year": 2024}}
|
||||
}
|
||||
assert _merge_dict(dest, source) == {
|
||||
"smartlife.iot.common.emeter": {
|
||||
"get_realtime": None,
|
||||
"get_daystat": {"month": 8, "year": 2024},
|
||||
}
|
||||
}
|
181
tests/iot/test_iotdimmer.py
Normal file
181
tests/iot/test_iotdimmer.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import pytest
|
||||
|
||||
from kasa import DeviceType, Module
|
||||
from kasa.iot import IotDimmer
|
||||
from tests.conftest import dimmer_iot, handle_turn_on, turn_on
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
async def test_set_brightness(dev):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, False)
|
||||
await dev.update()
|
||||
assert dev.is_on is False
|
||||
|
||||
await light.set_brightness(99)
|
||||
await dev.update()
|
||||
assert light.brightness == 99
|
||||
assert dev.is_on is True
|
||||
|
||||
await light.set_brightness(0)
|
||||
await dev.update()
|
||||
assert light.brightness == 99
|
||||
assert dev.is_on is False
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
@turn_on
|
||||
async def test_set_brightness_transition(dev, turn_on, mocker):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
query_helper = mocker.spy(IotDimmer, "_query_helper")
|
||||
|
||||
await light.set_brightness(99, transition=1000)
|
||||
query_helper.assert_called_with(
|
||||
mocker.ANY,
|
||||
"smartlife.iot.dimmer",
|
||||
"set_dimmer_transition",
|
||||
{"brightness": 99, "duration": 1000},
|
||||
)
|
||||
await dev.update()
|
||||
assert light.brightness == 99
|
||||
assert dev.is_on
|
||||
|
||||
await light.set_brightness(0, transition=1000)
|
||||
await dev.update()
|
||||
assert dev.is_on is False
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
async def test_set_brightness_invalid(dev):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
for invalid_brightness in [-1, 101]:
|
||||
with pytest.raises(ValueError, match="Invalid brightness"):
|
||||
await light.set_brightness(invalid_brightness)
|
||||
|
||||
for invalid_type in [0.5, "foo"]:
|
||||
with pytest.raises(TypeError, match="Brightness must be an integer"):
|
||||
await light.set_brightness(invalid_type)
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
async def test_set_brightness_invalid_transition(dev):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
for invalid_transition in [-1]:
|
||||
with pytest.raises(ValueError, match="Transition value .+? is not valid."):
|
||||
await light.set_brightness(1, transition=invalid_transition)
|
||||
for invalid_type in [0.5, "foo"]:
|
||||
with pytest.raises(TypeError, match="Transition must be integer"):
|
||||
await light.set_brightness(1, transition=invalid_type)
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
async def test_turn_on_transition(dev, mocker):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
query_helper = mocker.spy(IotDimmer, "_query_helper")
|
||||
original_brightness = light.brightness
|
||||
|
||||
await dev.turn_on(transition=1000)
|
||||
query_helper.assert_called_with(
|
||||
mocker.ANY,
|
||||
"smartlife.iot.dimmer",
|
||||
"set_dimmer_transition",
|
||||
{"brightness": original_brightness, "duration": 1000},
|
||||
)
|
||||
await dev.update()
|
||||
assert dev.is_on
|
||||
assert light.brightness == original_brightness
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
async def test_turn_off_transition(dev, mocker):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, True)
|
||||
query_helper = mocker.spy(IotDimmer, "_query_helper")
|
||||
original_brightness = light.brightness
|
||||
|
||||
await dev.turn_off(transition=1000)
|
||||
await dev.update()
|
||||
|
||||
assert dev.is_off
|
||||
assert light.brightness == original_brightness
|
||||
query_helper.assert_called_with(
|
||||
mocker.ANY,
|
||||
"smartlife.iot.dimmer",
|
||||
"set_dimmer_transition",
|
||||
{"brightness": 0, "duration": 1000},
|
||||
)
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
@turn_on
|
||||
async def test_set_dimmer_transition(dev, turn_on, mocker):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
query_helper = mocker.spy(IotDimmer, "_query_helper")
|
||||
|
||||
await dev.set_dimmer_transition(99, 1000)
|
||||
query_helper.assert_called_with(
|
||||
mocker.ANY,
|
||||
"smartlife.iot.dimmer",
|
||||
"set_dimmer_transition",
|
||||
{"brightness": 99, "duration": 1000},
|
||||
)
|
||||
await dev.update()
|
||||
assert dev.is_on
|
||||
assert light.brightness == 99
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
@turn_on
|
||||
async def test_set_dimmer_transition_to_off(dev, turn_on, mocker):
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
await handle_turn_on(dev, turn_on)
|
||||
original_brightness = light.brightness
|
||||
query_helper = mocker.spy(IotDimmer, "_query_helper")
|
||||
|
||||
await dev.set_dimmer_transition(0, 1000)
|
||||
await dev.update()
|
||||
|
||||
assert dev.is_off
|
||||
assert light.brightness == original_brightness
|
||||
query_helper.assert_called_with(
|
||||
mocker.ANY,
|
||||
"smartlife.iot.dimmer",
|
||||
"set_dimmer_transition",
|
||||
{"brightness": 0, "duration": 1000},
|
||||
)
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
async def test_set_dimmer_transition_invalid_brightness(dev):
|
||||
for invalid_brightness in [-1, 101]:
|
||||
with pytest.raises(ValueError, match="Invalid brightness value: "):
|
||||
await dev.set_dimmer_transition(invalid_brightness, 1000)
|
||||
|
||||
for invalid_type in [0.5, "foo"]:
|
||||
with pytest.raises(TypeError, match="Transition must be integer"):
|
||||
await dev.set_dimmer_transition(1, invalid_type)
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
async def test_set_dimmer_transition_invalid_transition(dev):
|
||||
for invalid_transition in [-1]:
|
||||
with pytest.raises(ValueError, match="Transition value .+? is not valid."):
|
||||
await dev.set_dimmer_transition(1, transition=invalid_transition)
|
||||
for invalid_type in [0.5, "foo"]:
|
||||
with pytest.raises(TypeError, match="Transition must be integer"):
|
||||
await dev.set_dimmer_transition(1, transition=invalid_type)
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
def test_device_type_dimmer(dev):
|
||||
assert dev.device_type == DeviceType.Dimmer
|
83
tests/iot/test_iotlightstrip.py
Normal file
83
tests/iot/test_iotlightstrip.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import pytest
|
||||
|
||||
from kasa import DeviceType, Module
|
||||
from kasa.iot import IotLightStrip
|
||||
from kasa.iot.modules import LightEffect
|
||||
from tests.conftest import lightstrip_iot
|
||||
|
||||
|
||||
@lightstrip_iot
|
||||
async def test_lightstrip_length(dev: IotLightStrip):
|
||||
assert dev.device_type == DeviceType.LightStrip
|
||||
assert dev.length == dev.sys_info["length"]
|
||||
|
||||
|
||||
@lightstrip_iot
|
||||
async def test_lightstrip_effect(dev: IotLightStrip):
|
||||
le: LightEffect = dev.modules[Module.LightEffect]
|
||||
assert isinstance(le._deprecated_effect, dict)
|
||||
for k in ["brightness", "custom", "enable", "id", "name"]:
|
||||
assert k in le._deprecated_effect
|
||||
|
||||
|
||||
@lightstrip_iot
|
||||
async def test_effects_lightstrip_set_effect(dev: IotLightStrip):
|
||||
le: LightEffect = dev.modules[Module.LightEffect]
|
||||
with pytest.raises(
|
||||
ValueError, match="The effect Not real is not a built in effect"
|
||||
):
|
||||
await le.set_effect("Not real")
|
||||
|
||||
await le.set_effect("Candy Cane")
|
||||
await dev.update()
|
||||
assert le.effect == "Candy Cane"
|
||||
|
||||
|
||||
@lightstrip_iot
|
||||
@pytest.mark.parametrize("brightness", [100, 50])
|
||||
async def test_effects_lightstrip_set_effect_brightness(
|
||||
dev: IotLightStrip, brightness, mocker
|
||||
):
|
||||
query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper")
|
||||
le: LightEffect = dev.modules[Module.LightEffect]
|
||||
|
||||
# test that default brightness works (100 for candy cane)
|
||||
if brightness == 100:
|
||||
await le.set_effect("Candy Cane")
|
||||
else:
|
||||
await le.set_effect("Candy Cane", brightness=brightness)
|
||||
|
||||
args, kwargs = query_helper.call_args_list[0]
|
||||
payload = args[2]
|
||||
assert payload["brightness"] == brightness
|
||||
|
||||
|
||||
@lightstrip_iot
|
||||
@pytest.mark.parametrize("transition", [500, 1000])
|
||||
async def test_effects_lightstrip_set_effect_transition(
|
||||
dev: IotLightStrip, transition, mocker
|
||||
):
|
||||
query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper")
|
||||
le: LightEffect = dev.modules[Module.LightEffect]
|
||||
|
||||
# test that default (500 for candy cane) transition works
|
||||
if transition == 500:
|
||||
await le.set_effect("Candy Cane")
|
||||
else:
|
||||
await le.set_effect("Candy Cane", transition=transition)
|
||||
|
||||
args, kwargs = query_helper.call_args_list[0]
|
||||
payload = args[2]
|
||||
assert payload["transition"] == transition
|
||||
|
||||
|
||||
@lightstrip_iot
|
||||
async def test_effects_lightstrip_has_effects(dev: IotLightStrip):
|
||||
le: LightEffect = dev.modules[Module.LightEffect]
|
||||
assert le is not None
|
||||
assert le.effect_list
|
||||
|
||||
|
||||
@lightstrip_iot
|
||||
def test_device_type_lightstrip(dev):
|
||||
assert dev.device_type == DeviceType.LightStrip
|
Reference in New Issue
Block a user