diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index e85493b1..780ac433 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -23,6 +23,8 @@ class SmartDimmer(SmartPlug): Refer to SmartPlug for the full API. """ + DIMMER_SERVICE = "smartlife.iot.dimmer" + def __init__(self, host: str) -> None: super().__init__(host) self._device_type = DeviceType.Dimmer @@ -41,19 +43,83 @@ class SmartDimmer(SmartPlug): return int(sys_info["brightness"]) @requires_update - async def set_brightness(self, value: int): - """Set the new dimmer brightness level in percentage.""" + async def set_brightness(self, brightness: int, *, transition: int = None): + """Set the new dimmer brightness level in percentage. + + :param int transition: transition duration in milliseconds. + Using a transition will cause the dimmer to turn on. + """ if not self.is_dimmable: raise SmartDeviceException("Device is not dimmable.") - if not isinstance(value, int): - raise ValueError("Brightness must be integer, " "not of %s.", type(value)) - elif 0 <= value <= 100: - return await self._query_helper( - "smartlife.iot.dimmer", "set_brightness", {"brightness": value} + if not isinstance(brightness, int): + raise ValueError( + "Brightness must be integer, " "not of %s.", type(brightness) ) - else: - raise ValueError("Brightness value %s is not valid." % value) + + if not 0 <= brightness <= 100: + raise ValueError("Brightness value %s is not valid." % brightness) + + # Dimmers do not support a brightness of 0, but bulbs do. + # Coerce 0 to 1 to maintain the same interface between dimmers and bulbs. + if brightness == 0: + brightness = 1 + + if transition is not None: + return await self.set_dimmer_transition(brightness, transition) + + return await self._query_helper( + self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness} + ) + + async def turn_off(self, *, transition: int = None): + """Turn the bulb off. + + :param int transition: transition duration in milliseconds. + """ + if transition is not None: + return await self.set_dimmer_transition(brightness=0, transition=transition) + + return await super().turn_off() + + @requires_update + async def turn_on(self, *, transition: int = None): + """Turn the bulb on. + + :param int transition: transition duration in milliseconds. + """ + if transition is not None: + return await self.set_dimmer_transition( + brightness=self.brightness, transition=transition + ) + + return await super().turn_on() + + async def set_dimmer_transition(self, brightness: int, transition: int): + """Turn the bulb on to brightness percentage over transition milliseconds. + + A brightness value of 0 will turn off the dimmer. + """ + if not isinstance(brightness, int): + raise ValueError( + "Brightness must be integer, " "not of %s.", type(brightness) + ) + + if not 0 <= brightness <= 100: + raise ValueError("Brightness value %s is not valid." % brightness) + + if not isinstance(transition, int): + raise ValueError( + "Transition must be integer, " "not of %s.", type(transition) + ) + if transition <= 0: + raise ValueError("Transition value %s is not valid." % transition) + + return await self._query_helper( + self.DIMMER_SERVICE, + "set_dimmer_transition", + {"brightness": brightness, "duration": transition}, + ) @property # type: ignore @requires_update diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index c54dec3b..2f2be2ef 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -315,6 +315,15 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): _LOGGER.debug("Setting brightness to %s", x) self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] + def set_hs220_dimmer_transition(self, x, *args): + _LOGGER.debug("Setting dimmer transition to %s", x) + brightness = x["brightness"] + if brightness == 0: + self.proto["system"]["get_sysinfo"]["relay_state"] = 0 + else: + self.proto["system"]["get_sysinfo"]["relay_state"] = 1 + self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] + def transition_light_state(self, x, *args): _LOGGER.debug("Setting light state to %s", x) light_state = self.proto["system"]["get_sysinfo"]["light_state"] @@ -392,7 +401,10 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): "set_timezone": None, }, # HS220 brightness, different setter and getter - "smartlife.iot.dimmer": {"set_brightness": set_hs220_brightness}, + "smartlife.iot.dimmer": { + "set_brightness": set_hs220_brightness, + "set_dimmer_transition": set_hs220_dimmer_transition, + }, } async def query(self, host, request, port=9999): diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py new file mode 100644 index 00000000..b5e98b78 --- /dev/null +++ b/kasa/tests/test_dimmer.py @@ -0,0 +1,134 @@ +import pytest + +from kasa import SmartDimmer + +from .conftest import dimmer, handle_turn_on, turn_on + + +@dimmer +@turn_on +async def test_set_brightness(dev, turn_on): + await handle_turn_on(dev, turn_on) + + await dev.set_brightness(99) + assert dev.brightness == 99 + assert dev.is_on == turn_on + + await dev.set_brightness(0) + assert dev.brightness == 1 + assert dev.is_on == turn_on + + +@dimmer +@turn_on +async def test_set_brightness_transition(dev, turn_on, mocker): + await handle_turn_on(dev, turn_on) + query_helper = mocker.spy(SmartDimmer, "_query_helper") + + await dev.set_brightness(99, transition=1000) + + assert dev.brightness == 99 + assert dev.is_on + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 99, "duration": 1000}, + ) + + await dev.set_brightness(0, transition=1000) + assert dev.brightness == 1 + + +@dimmer +async def test_set_brightness_invalid(dev): + for invalid_brightness in [-1, 101, 0.5]: + with pytest.raises(ValueError): + await dev.set_brightness(invalid_brightness) + + for invalid_transition in [-1, 0, 0.5]: + with pytest.raises(ValueError): + await dev.set_brightness(1, transition=invalid_transition) + + +@dimmer +async def test_turn_on_transition(dev, mocker): + query_helper = mocker.spy(SmartDimmer, "_query_helper") + original_brightness = dev.brightness + + await dev.turn_on(transition=1000) + + assert dev.is_on + assert dev.brightness == original_brightness + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": original_brightness, "duration": 1000}, + ) + + +@dimmer +async def test_turn_off_transition(dev, mocker): + await handle_turn_on(dev, True) + query_helper = mocker.spy(SmartDimmer, "_query_helper") + original_brightness = dev.brightness + + await dev.turn_off(transition=1000) + + assert dev.is_off + assert dev.brightness == original_brightness + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 0, "duration": 1000}, + ) + + +@dimmer +@turn_on +async def test_set_dimmer_transition(dev, turn_on, mocker): + await handle_turn_on(dev, turn_on) + query_helper = mocker.spy(SmartDimmer, "_query_helper") + + await dev.set_dimmer_transition(99, 1000) + + assert dev.is_on + assert dev.brightness == 99 + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 99, "duration": 1000}, + ) + + +@dimmer +@turn_on +async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): + await handle_turn_on(dev, turn_on) + original_brightness = dev.brightness + query_helper = mocker.spy(SmartDimmer, "_query_helper") + + await dev.set_dimmer_transition(0, 1000) + + assert dev.is_off + assert dev.brightness == original_brightness + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 0, "duration": 1000}, + ) + + +@dimmer +async def test_set_dimmer_transition_invalid(dev): + for invalid_brightness in [-1, 101, 0.5]: + with pytest.raises(ValueError): + await dev.set_dimmer_transition(invalid_brightness, 1000) + + for invalid_transition in [-1, 0, 0.5]: + with pytest.raises(ValueError): + await dev.set_dimmer_transition(1, invalid_transition)