mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 11:13:34 +00:00
Fix set_state for common light modules (#929)
PR contains a number of fixes from testing with HA devices: - Fixes a bug with turning the light on and off via `set_state` - Aligns `set_brightness` behaviour across `smart` and `iot` devices such that a value of 0 is off. - Aligns `set_brightness` behaviour for `IotDimmer` such that setting the brightness turns on the device with a transition of 1ms. ([HA comment](https://github.com/home-assistant/core/pull/117839#discussion_r1608720006)) - Fixes a typing issue in `LightState`. - Adds `ColorTempRange` and `HSV` to `__init__.py` - Adds a `state` property to the interface returning `LightState` for validating `set_state` changes. - Adds tests for `set_state`
This commit is contained in:
parent
5e619af29f
commit
db6e335346
@ -35,7 +35,7 @@ from kasa.exceptions import (
|
||||
UnsupportedDeviceError,
|
||||
)
|
||||
from kasa.feature import Feature
|
||||
from kasa.interfaces.light import Light, LightState
|
||||
from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState
|
||||
from kasa.iotprotocol import (
|
||||
IotProtocol,
|
||||
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401
|
||||
@ -60,6 +60,8 @@ __all__ = [
|
||||
"EmeterStatus",
|
||||
"Device",
|
||||
"Light",
|
||||
"ColorTempRange",
|
||||
"HSV",
|
||||
"Plug",
|
||||
"Module",
|
||||
"KasaException",
|
||||
|
@ -18,7 +18,7 @@ class LightState:
|
||||
hue: int | None = None
|
||||
saturation: int | None = None
|
||||
color_temp: int | None = None
|
||||
transition: bool | None = None
|
||||
transition: int | None = None
|
||||
|
||||
|
||||
class ColorTempRange(NamedTuple):
|
||||
@ -128,6 +128,11 @@ class Light(Module, ABC):
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def state(self) -> LightState:
|
||||
"""Return the current light state."""
|
||||
|
||||
@abstractmethod
|
||||
async def set_state(self, state: LightState) -> dict:
|
||||
"""Set the light state."""
|
||||
|
@ -329,6 +329,9 @@ class IotBulb(IotDevice):
|
||||
if transition is not None:
|
||||
state["transition_period"] = transition
|
||||
|
||||
if "brightness" in state:
|
||||
self._raise_for_invalid_brightness(state["brightness"])
|
||||
|
||||
# if no on/off is defined, turn on the light
|
||||
if "on_off" not in state:
|
||||
state["on_off"] = 1
|
||||
|
@ -168,6 +168,9 @@ class IotDimmer(IotPlug):
|
||||
if not 0 <= brightness <= 100:
|
||||
raise ValueError("Brightness value %s is not valid." % brightness)
|
||||
|
||||
# If zero set to 1 millisecond
|
||||
if transition == 0:
|
||||
transition = 1
|
||||
if not isinstance(transition, int):
|
||||
raise ValueError(
|
||||
"Transition must be integer, " "not of %s.", type(transition)
|
||||
|
@ -25,6 +25,7 @@ class Light(IotModule, LightInterface):
|
||||
"""Implementation of brightness module."""
|
||||
|
||||
_device: IotBulb | IotDimmer
|
||||
_light_state: LightState
|
||||
|
||||
def _initialize_features(self):
|
||||
"""Initialize features."""
|
||||
@ -102,12 +103,14 @@ class Light(IotModule, LightInterface):
|
||||
async def set_brightness(
|
||||
self, brightness: int, *, transition: int | None = None
|
||||
) -> dict:
|
||||
"""Set the brightness in percentage.
|
||||
"""Set the brightness in percentage. A value of 0 will turn off the light.
|
||||
|
||||
:param int brightness: brightness in percent
|
||||
:param int transition: transition in milliseconds.
|
||||
"""
|
||||
return await self._device._set_brightness(brightness, transition=transition)
|
||||
return await self.set_state(
|
||||
LightState(brightness=brightness, transition=transition)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_color(self) -> bool:
|
||||
@ -202,15 +205,54 @@ class Light(IotModule, LightInterface):
|
||||
|
||||
async def set_state(self, state: LightState) -> dict:
|
||||
"""Set the light state."""
|
||||
if (bulb := self._get_bulb_device()) is None:
|
||||
return await self.set_brightness(state.brightness or 0)
|
||||
# iot protocol Dimmers and smart protocol devices do not support
|
||||
# brightness of 0 so 0 will turn off all devices for consistency
|
||||
if (bulb := self._get_bulb_device()) is None: # Dimmer
|
||||
if state.brightness == 0 or state.light_on is False:
|
||||
return await self._device.turn_off(transition=state.transition)
|
||||
elif state.brightness:
|
||||
# set_dimmer_transition will turn on the device
|
||||
return await self._device.set_dimmer_transition(
|
||||
state.brightness, state.transition or 0
|
||||
)
|
||||
return await self._device.turn_on(transition=state.transition)
|
||||
else:
|
||||
transition = state.transition
|
||||
state_dict = asdict(state)
|
||||
state_dict = {k: v for k, v in state_dict.items() if v is not None}
|
||||
if "transition" in state_dict:
|
||||
del state_dict["transition"]
|
||||
state_dict["on_off"] = 1 if state.light_on is None else int(state.light_on)
|
||||
if state_dict.get("brightness") == 0:
|
||||
state_dict["on_off"] = 0
|
||||
del state_dict["brightness"]
|
||||
# If light on state not set default to on.
|
||||
elif state.light_on is None:
|
||||
state_dict["on_off"] = 1
|
||||
else:
|
||||
state_dict["on_off"] = int(state.light_on)
|
||||
return await bulb._set_light_state(state_dict, transition=transition)
|
||||
|
||||
@property
|
||||
def state(self) -> LightState:
|
||||
"""Return the current light state."""
|
||||
return self._light_state
|
||||
|
||||
def _post_update_hook(self) -> None:
|
||||
if self._device.is_on is False:
|
||||
state = LightState(light_on=False)
|
||||
else:
|
||||
state = LightState(light_on=True)
|
||||
if self.is_dimmable:
|
||||
state.brightness = self.brightness
|
||||
if self.is_color:
|
||||
hsv = self.hsv
|
||||
state.hue = hsv.hue
|
||||
state.saturation = hsv.saturation
|
||||
if self.is_variable_color_temp:
|
||||
state.color_temp = self.color_temp
|
||||
self._light_state = state
|
||||
|
||||
async def _deprecated_set_light_state(
|
||||
self, state: dict, *, transition: int | None = None
|
||||
) -> dict:
|
||||
|
@ -14,6 +14,8 @@ from ..smartmodule import SmartModule
|
||||
class Light(SmartModule, LightInterface):
|
||||
"""Implementation of a light."""
|
||||
|
||||
_light_state: LightState
|
||||
|
||||
def query(self) -> dict:
|
||||
"""Query to execute during the update cycle."""
|
||||
return {}
|
||||
@ -131,9 +133,34 @@ class Light(SmartModule, LightInterface):
|
||||
"""Set the light state."""
|
||||
state_dict = asdict(state)
|
||||
# brightness of 0 turns off the light, it's not a valid brightness
|
||||
if state.brightness and state.brightness == 0:
|
||||
if state.brightness == 0:
|
||||
state_dict["device_on"] = False
|
||||
del state_dict["brightness"]
|
||||
elif state.light_on is not None:
|
||||
state_dict["device_on"] = state.light_on
|
||||
del state_dict["light_on"]
|
||||
else:
|
||||
state_dict["device_on"] = True
|
||||
|
||||
params = {k: v for k, v in state_dict.items() if v is not None}
|
||||
return await self.call("set_device_info", params)
|
||||
|
||||
@property
|
||||
def state(self) -> LightState:
|
||||
"""Return the current light state."""
|
||||
return self._light_state
|
||||
|
||||
def _post_update_hook(self) -> None:
|
||||
if self._device.is_on is False:
|
||||
state = LightState(light_on=False)
|
||||
else:
|
||||
state = LightState(light_on=True)
|
||||
if self.is_dimmable:
|
||||
state.brightness = self.brightness
|
||||
if self.is_color:
|
||||
hsv = self.hsv
|
||||
state.hue = hsv.hue
|
||||
state.saturation = hsv.saturation
|
||||
if self.is_variable_color_temp:
|
||||
state.color_temp = self.color_temp
|
||||
self._light_state = state
|
||||
|
@ -241,7 +241,7 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
|
||||
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)
|
||||
set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000)
|
||||
|
||||
|
||||
@dimmable_iot
|
||||
|
@ -4,6 +4,7 @@ from pytest_mock import MockerFixture
|
||||
from kasa import Device, LightState, Module
|
||||
from kasa.tests.device_fixtures import (
|
||||
bulb_iot,
|
||||
bulb_smart,
|
||||
dimmable_iot,
|
||||
dimmer_iot,
|
||||
lightstrip_iot,
|
||||
@ -40,6 +41,8 @@ light_preset_smart = parametrize(
|
||||
|
||||
light_preset = parametrize_combine([light_preset_smart, bulb_iot])
|
||||
|
||||
light = parametrize_combine([bulb_smart, bulb_iot, dimmable])
|
||||
|
||||
|
||||
@led
|
||||
async def test_led_module(dev: Device, mocker: MockerFixture):
|
||||
@ -139,6 +142,30 @@ async def test_light_brightness(dev: Device):
|
||||
await light.set_brightness(feature.maximum_value + 10)
|
||||
|
||||
|
||||
@light
|
||||
async def test_light_set_state(dev: Device):
|
||||
"""Test brightness setter and getter."""
|
||||
assert isinstance(dev, Device)
|
||||
light = dev.modules.get(Module.Light)
|
||||
assert light
|
||||
|
||||
await light.set_state(LightState(light_on=False))
|
||||
await dev.update()
|
||||
assert light.state.light_on is False
|
||||
|
||||
await light.set_state(LightState(light_on=True))
|
||||
await dev.update()
|
||||
assert light.state.light_on is True
|
||||
|
||||
await light.set_state(LightState(brightness=0))
|
||||
await dev.update()
|
||||
assert light.state.light_on is False
|
||||
|
||||
await light.set_state(LightState(brightness=50))
|
||||
await dev.update()
|
||||
assert light.state.light_on is True
|
||||
|
||||
|
||||
@light_preset
|
||||
async def test_light_preset_module(dev: Device, mocker: MockerFixture):
|
||||
"""Test light preset module."""
|
||||
@ -148,7 +175,6 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture):
|
||||
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
|
||||
@ -157,7 +183,6 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture):
|
||||
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"
|
||||
@ -165,6 +190,7 @@ async def test_light_preset_module(dev: Device, mocker: MockerFixture):
|
||||
if len(preset_list) == 1:
|
||||
return
|
||||
|
||||
call = mocker.spy(light_mod, "set_state")
|
||||
second_preset = preset_list[1]
|
||||
await preset_mod.set_preset(second_preset)
|
||||
assert call.call_count == 1
|
||||
|
@ -7,19 +7,19 @@ from .conftest import dimmer_iot, handle_turn_on, turn_on
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
@turn_on
|
||||
async def test_set_brightness(dev, turn_on):
|
||||
await handle_turn_on(dev, turn_on)
|
||||
async def test_set_brightness(dev):
|
||||
await handle_turn_on(dev, False)
|
||||
assert dev.is_on is False
|
||||
|
||||
await dev.set_brightness(99)
|
||||
await dev.update()
|
||||
assert dev.brightness == 99
|
||||
assert dev.is_on == turn_on
|
||||
assert dev.is_on is True
|
||||
|
||||
await dev.set_brightness(0)
|
||||
await dev.update()
|
||||
assert dev.brightness == 1
|
||||
assert dev.is_on == turn_on
|
||||
assert dev.brightness == 99
|
||||
assert dev.is_on is False
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
@ -41,7 +41,7 @@ async def test_set_brightness_transition(dev, turn_on, mocker):
|
||||
|
||||
await dev.set_brightness(0, transition=1000)
|
||||
await dev.update()
|
||||
assert dev.brightness == 1
|
||||
assert dev.is_on is False
|
||||
|
||||
|
||||
@dimmer_iot
|
||||
@ -50,7 +50,7 @@ async def test_set_brightness_invalid(dev):
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_brightness(invalid_brightness)
|
||||
|
||||
for invalid_transition in [-1, 0, 0.5]:
|
||||
for invalid_transition in [-1, 0.5]:
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_brightness(1, transition=invalid_transition)
|
||||
|
||||
@ -133,7 +133,7 @@ async def test_set_dimmer_transition_invalid(dev):
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_dimmer_transition(invalid_brightness, 1000)
|
||||
|
||||
for invalid_transition in [-1, 0, 0.5]:
|
||||
for invalid_transition in [-1, 0.5]:
|
||||
with pytest.raises(ValueError):
|
||||
await dev.set_dimmer_transition(1, invalid_transition)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user