Add light presets common module to devices. (#907)

Adds light preset common module for switching to presets and saving presets.
Deprecates the `presets` attribute and `save_preset` method from the `bulb` 
interface in favour of the modular approach.  Allows setting preset for `iot` 
which was not previously supported.
This commit is contained in:
Steven B
2024-05-19 11:20:18 +01:00
committed by GitHub
parent 1ba5c73279
commit 273c541fcc
20 changed files with 612 additions and 73 deletions

View File

@@ -157,6 +157,8 @@ class FakeSmartTransport(BaseTransport):
elif child_method == "set_device_info":
info.update(child_params)
return {"error_code": 0}
elif child_method == "set_preset_rules":
return self._set_child_preset_rules(info, child_params)
elif (
# FIXTURE_MISSING is for service calls not in place when
# SMART fixtures started to be generated
@@ -205,6 +207,30 @@ class FakeSmartTransport(BaseTransport):
info["get_led_info"]["led_status"] = params["led_rule"] != "never"
info["get_led_info"]["led_rule"] = params["led_rule"]
def _set_preset_rules(self, info, params):
"""Set or remove values as per the device behaviour."""
if "brightness" not in info["get_preset_rules"]:
return {"error_code": SmartErrorCode.PARAMS_ERROR}
info["get_preset_rules"]["brightness"] = params["brightness"]
return {"error_code": 0}
def _set_child_preset_rules(self, info, params):
"""Set or remove values as per the device behaviour."""
# So far the only child device with light preset (KS240) has the
# data available to read in the device_info. If a child device
# appears that doesn't have this this will need to be extended.
if "preset_state" not in info:
return {"error_code": SmartErrorCode.PARAMS_ERROR}
info["preset_state"] = [{"brightness": b} for b in params["brightness"]]
return {"error_code": 0}
def _edit_preset_rules(self, info, params):
"""Set or remove values as per the device behaviour."""
if "states" not in info["get_preset_rules"] is None:
return {"error_code": SmartErrorCode.PARAMS_ERROR}
info["get_preset_rules"]["states"][params["index"]] = params["state"]
return {"error_code": 0}
def _send_request(self, request_dict: dict):
method = request_dict["method"]
params = request_dict["params"]
@@ -276,6 +302,10 @@ class FakeSmartTransport(BaseTransport):
elif method == "set_led_info":
self._set_led_info(info, params)
return {"error_code": 0}
elif method == "set_preset_rules":
return self._set_preset_rules(info, params)
elif method == "edit_preset_rules":
return self._edit_preset_rules(info, params)
elif method[:4] == "set_":
target_method = f"get_{method[4:]}"
info[target_method].update(params)

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import pytest
from voluptuous import (
All,
@@ -7,7 +9,7 @@ from voluptuous import (
Schema,
)
from kasa import Device, DeviceType, KasaException, LightPreset, Module
from kasa import Device, DeviceType, IotLightPreset, KasaException, Module
from kasa.iot import IotBulb, IotDimmer
from .conftest import (
@@ -85,7 +87,7 @@ async def test_hsv(dev: Device, turn_on):
@color_bulb_iot
async def test_set_hsv_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.set_hsv(10, 10, 100, transition=1000)
set_light_state.assert_called_with(
@@ -158,7 +160,7 @@ async def test_try_set_colortemp(dev: Device, turn_on):
@variable_temp_iot
async def test_set_color_temp_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.set_color_temp(2700, transition=100)
set_light_state.assert_called_with({"color_temp": 2700}, transition=100)
@@ -224,7 +226,7 @@ async def test_dimmable_brightness(dev: IotBulb, turn_on):
@bulb_iot
async def test_turn_on_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
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)
@@ -236,7 +238,7 @@ async def test_turn_on_transition(dev: IotBulb, mocker):
@bulb_iot
async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb.set_light_state")
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.set_brightness(10, transition=1000)
set_light_state.assert_called_with({"brightness": 10}, transition=1000)
@@ -297,14 +299,14 @@ async def test_modify_preset(dev: IotBulb, mocker):
if not dev.presets:
pytest.skip("Some strips do not support presets")
data = {
data: dict[str, int | None] = {
"index": 0,
"brightness": 10,
"hue": 0,
"saturation": 0,
"color_temp": 0,
}
preset = LightPreset(**data)
preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type]
assert preset.index == 0
assert preset.brightness == 10
@@ -318,7 +320,7 @@ async def test_modify_preset(dev: IotBulb, mocker):
with pytest.raises(KasaException):
await dev.save_preset(
LightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0)
IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg]
)
@@ -327,11 +329,11 @@ async def test_modify_preset(dev: IotBulb, mocker):
("preset", "payload"),
[
(
LightPreset(index=0, hue=0, brightness=1, saturation=0),
IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg]
{"index": 0, "hue": 0, "brightness": 1, "saturation": 0},
),
(
LightPreset(index=0, brightness=1, id="testid", mode=2, custom=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},
),
],

View File

@@ -1,8 +1,9 @@
import pytest
from pytest_mock import MockerFixture
from kasa import Device, Module
from kasa import Device, LightState, Module
from kasa.tests.device_fixtures import (
bulb_iot,
dimmable_iot,
dimmer_iot,
lightstrip_iot,
@@ -33,6 +34,12 @@ dimmable_smart = parametrize(
)
dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot])
light_preset_smart = parametrize(
"has light preset smart", component_filter="preset", protocol_filter={"SMART"}
)
light_preset = parametrize_combine([light_preset_smart, bulb_iot])
@led
async def test_led_module(dev: Device, mocker: MockerFixture):
@@ -130,3 +137,80 @@ async def test_light_brightness(dev: Device):
with pytest.raises(ValueError):
await light.set_brightness(feature.maximum_value + 10)
@light_preset
async def test_light_preset_module(dev: Device, mocker: MockerFixture):
"""Test light preset module."""
preset_mod = dev.modules[Module.LightPreset]
assert preset_mod
light_mod = dev.modules[Module.Light]
assert light_mod
feat = dev.features["light_preset"]
call = mocker.spy(light_mod, "set_state")
preset_list = preset_mod.preset_list
assert "Not set" in preset_list
assert preset_list.index("Not set") == 0
assert preset_list == feat.choices
assert preset_mod.has_save_preset is True
await light_mod.set_brightness(33) # Value that should not be a preset
assert call.call_count == 0
await dev.update()
assert preset_mod.preset == "Not set"
assert feat.value == "Not set"
if len(preset_list) == 1:
return
second_preset = preset_list[1]
await preset_mod.set_preset(second_preset)
assert call.call_count == 1
await dev.update()
assert preset_mod.preset == second_preset
assert feat.value == second_preset
last_preset = preset_list[len(preset_list) - 1]
await preset_mod.set_preset(last_preset)
assert call.call_count == 2
await dev.update()
assert preset_mod.preset == last_preset
assert feat.value == last_preset
# Test feature set
await feat.set_value(second_preset)
assert call.call_count == 3
await dev.update()
assert preset_mod.preset == second_preset
assert feat.value == second_preset
with pytest.raises(ValueError):
await preset_mod.set_preset("foobar")
assert call.call_count == 3
@light_preset
async def test_light_preset_save(dev: Device, mocker: MockerFixture):
"""Test saving a new preset value."""
preset_mod = dev.modules[Module.LightPreset]
assert preset_mod
preset_list = preset_mod.preset_list
if len(preset_list) == 1:
return
second_preset = preset_list[1]
if preset_mod.preset_states_list[0].hue is None:
new_preset = LightState(brightness=52)
else:
new_preset = LightState(brightness=52, color_temp=3000, hue=20, saturation=30)
await preset_mod.save_preset(second_preset, new_preset)
await dev.update()
new_preset_state = preset_mod.preset_states_list[0]
assert (
new_preset_state.brightness == new_preset.brightness
and new_preset_state.hue == new_preset.hue
and new_preset_state.saturation == new_preset.saturation
and new_preset_state.color_temp == new_preset.color_temp
)

View File

@@ -1,5 +1,7 @@
"""Tests for all devices."""
from __future__ import annotations
import importlib
import inspect
import pkgutil
@@ -11,6 +13,7 @@ import pytest
import kasa
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
from kasa.iot import IotDevice
from kasa.iot.modules import IotLightPreset
from kasa.smart import SmartChildDevice, SmartDevice
@@ -238,3 +241,28 @@ async def test_deprecated_other_attributes(dev: Device):
await _test_attribute(dev, "led", bool(led_module), "Led")
await _test_attribute(dev, "set_led", bool(led_module), "Led", True)
async def test_deprecated_light_preset_attributes(dev: Device):
preset = dev.modules.get(Module.LightPreset)
exc: type[AttributeError] | type[KasaException] | None = (
AttributeError if not preset else None
)
await _test_attribute(dev, "presets", bool(preset), "LightPreset", will_raise=exc)
exc = None
# deprecated save_preset not implemented for smart devices as it's unlikely anyone
# has an existing reliance on this for the newer devices.
if not preset or isinstance(dev, SmartDevice):
exc = AttributeError
elif len(preset.preset_states_list) == 0:
exc = KasaException
await _test_attribute(
dev,
"save_preset",
bool(preset),
"LightPreset",
IotLightPreset(index=0, hue=100, brightness=100, saturation=0, color_temp=0), # type: ignore[call-arg]
will_raise=exc,
)