diff --git a/kasa/__init__.py b/kasa/__init__.py index ac10c12f..d436155e 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -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", diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index f121d9c6..207014ca 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -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.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index cca1e792..36209360 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -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 diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 740d9bb5..ca182e49 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -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) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 6bbb8894..8c4e22c9 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -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: diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 9a07d3e2..0a255bb2 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -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 diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 97ae85a3..b2653015 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -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 diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 52030307..0cdb32ad 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -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 diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 06150d39..5831c019 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -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)