mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
a01247d48f
Python 3.11 ships with latest Debian Bookworm. pypy is not that widely used with this library based on statistics. It could be added back when pypy supports python 3.11.
497 lines
14 KiB
Python
497 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
from voluptuous import (
|
|
All,
|
|
Boolean,
|
|
Optional,
|
|
Range,
|
|
Schema,
|
|
)
|
|
|
|
from kasa import Device, DeviceType, IotLightPreset, KasaException, LightState, Module
|
|
from kasa.iot import IotBulb, IotDimmer
|
|
from kasa.iot.modules import LightPreset as IotLightPresetModule
|
|
|
|
from .conftest import (
|
|
bulb,
|
|
bulb_iot,
|
|
color_bulb,
|
|
color_bulb_iot,
|
|
dimmable_iot,
|
|
handle_turn_on,
|
|
non_color_bulb,
|
|
non_dimmable_iot,
|
|
non_variable_temp,
|
|
turn_on,
|
|
variable_temp,
|
|
variable_temp_iot,
|
|
variable_temp_smart,
|
|
)
|
|
from .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
|
|
async def test_state_attributes(dev: Device):
|
|
assert "Cloud connection" in dev.state_information
|
|
assert isinstance(dev.state_information["Cloud connection"], bool)
|
|
|
|
|
|
@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
|
|
@turn_on
|
|
async def test_hsv(dev: Device, turn_on):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
await handle_turn_on(dev, turn_on)
|
|
assert light.is_color
|
|
|
|
hue, saturation, brightness = light.hsv
|
|
assert 0 <= hue <= 360
|
|
assert 0 <= saturation <= 100
|
|
assert 0 <= brightness <= 100
|
|
|
|
await light.set_hsv(hue=1, saturation=1, value=1)
|
|
|
|
await dev.update()
|
|
hue, saturation, brightness = light.hsv
|
|
assert hue == 1
|
|
assert saturation == 1
|
|
assert brightness == 1
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@color_bulb
|
|
@turn_on
|
|
@pytest.mark.parametrize(
|
|
("hue", "sat", "brightness", "exception_cls", "error"),
|
|
[
|
|
pytest.param(-1, 0, 0, ValueError, "Invalid hue", id="hue out of range"),
|
|
pytest.param(361, 0, 0, ValueError, "Invalid hue", id="hue out of range"),
|
|
pytest.param(
|
|
0.5, 0, 0, TypeError, "Hue must be an integer", id="hue invalid type"
|
|
),
|
|
pytest.param(
|
|
"foo", 0, 0, TypeError, "Hue must be an integer", id="hue invalid type"
|
|
),
|
|
pytest.param(
|
|
0, -1, 0, ValueError, "Invalid saturation", id="saturation out of range"
|
|
),
|
|
pytest.param(
|
|
0, 101, 0, ValueError, "Invalid saturation", id="saturation out of range"
|
|
),
|
|
pytest.param(
|
|
0,
|
|
0.5,
|
|
0,
|
|
TypeError,
|
|
"Saturation must be an integer",
|
|
id="saturation invalid type",
|
|
),
|
|
pytest.param(
|
|
0,
|
|
"foo",
|
|
0,
|
|
TypeError,
|
|
"Saturation must be an integer",
|
|
id="saturation invalid type",
|
|
),
|
|
pytest.param(
|
|
0, 0, -1, ValueError, "Invalid brightness", id="brightness out of range"
|
|
),
|
|
pytest.param(
|
|
0, 0, 101, ValueError, "Invalid brightness", id="brightness out of range"
|
|
),
|
|
pytest.param(
|
|
0,
|
|
0,
|
|
0.5,
|
|
TypeError,
|
|
"Brightness must be an integer",
|
|
id="brightness invalid type",
|
|
),
|
|
pytest.param(
|
|
0,
|
|
0,
|
|
"foo",
|
|
TypeError,
|
|
"Brightness must be an integer",
|
|
id="brightness invalid type",
|
|
),
|
|
],
|
|
)
|
|
async def test_invalid_hsv(
|
|
dev: Device, turn_on, hue, sat, brightness, exception_cls, error
|
|
):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
await handle_turn_on(dev, turn_on)
|
|
assert light.is_color
|
|
with pytest.raises(exception_cls, match=error):
|
|
await light.set_hsv(hue, sat, brightness)
|
|
|
|
|
|
@color_bulb
|
|
@pytest.mark.skip("requires color feature")
|
|
async def test_color_state_information(dev: Device):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
assert "HSV" in dev.state_information
|
|
assert dev.state_information["HSV"] == light.hsv
|
|
|
|
|
|
@non_color_bulb
|
|
async def test_hsv_on_non_color(dev: Device):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
assert not light.is_color
|
|
|
|
with pytest.raises(KasaException):
|
|
await light.set_hsv(0, 0, 0)
|
|
with pytest.raises(KasaException):
|
|
print(light.hsv)
|
|
|
|
|
|
@variable_temp
|
|
@pytest.mark.skip("requires colortemp module")
|
|
async def test_variable_temp_state_information(dev: Device):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
assert "Color temperature" in dev.state_information
|
|
assert dev.state_information["Color temperature"] == light.color_temp
|
|
|
|
|
|
@variable_temp
|
|
@turn_on
|
|
async def test_try_set_colortemp(dev: Device, turn_on):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
await handle_turn_on(dev, turn_on)
|
|
await light.set_color_temp(2700)
|
|
await dev.update()
|
|
assert light.color_temp == 2700
|
|
|
|
|
|
@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
|
|
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
|
|
|
|
|
|
@variable_temp_smart
|
|
async def test_smart_temp_range(dev: Device):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
assert light.valid_temperature_range
|
|
|
|
|
|
@variable_temp
|
|
async def test_out_of_range_temperature(dev: Device):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
with pytest.raises(
|
|
ValueError, match=r"Temperature should be between \d+ and \d+, was 1000"
|
|
):
|
|
await light.set_color_temp(1000)
|
|
with pytest.raises(
|
|
ValueError, match=r"Temperature should be between \d+ and \d+, was 10000"
|
|
):
|
|
await light.set_color_temp(10000)
|
|
|
|
|
|
@non_variable_temp
|
|
async def test_non_variable_temp(dev: Device):
|
|
light = dev.modules.get(Module.Light)
|
|
assert light
|
|
with pytest.raises(KasaException):
|
|
await light.set_color_temp(2700)
|
|
|
|
with pytest.raises(KasaException):
|
|
print(light.valid_temperature_range)
|
|
|
|
with pytest.raises(KasaException):
|
|
print(light.color_temp)
|
|
|
|
|
|
@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)),
|
|
"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)),
|
|
}
|
|
),
|
|
"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
|
|
def test_device_type_bulb(dev: Device):
|
|
assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip}
|