Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion kasa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
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
Expand All @@ -60,6 +60,8 @@
"EmeterStatus",
"Device",
"Light",
"ColorTempRange",
"HSV",
"Plug",
"Module",
"KasaException",
Expand Down
7 changes: 6 additions & 1 deletion kasa/interfaces/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -128,6 +128,11 @@ async def set_brightness(
: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."""
3 changes: 3 additions & 0 deletions kasa/iot/iotbulb.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ async def _set_light_state(
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
Expand Down
3 changes: 3 additions & 0 deletions kasa/iot/iotdimmer.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ async def set_dimmer_transition(self, brightness: int, transition: int):
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)
Expand Down
50 changes: 46 additions & 4 deletions kasa/iot/modules/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Light(IotModule, LightInterface):
"""Implementation of brightness module."""

_device: IotBulb | IotDimmer
_light_state: LightState

def _initialize_features(self):
"""Initialize features."""
Expand Down Expand Up @@ -102,12 +103,14 @@ def brightness(self) -> int:
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:
Expand Down Expand Up @@ -202,15 +205,54 @@ async def set_color_temp(

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:
Expand Down
29 changes: 28 additions & 1 deletion kasa/smart/modules/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
class Light(SmartModule, LightInterface):
"""Implementation of a light."""

_light_state: LightState

def query(self) -> dict:
"""Query to execute during the update cycle."""
return {}
Expand Down Expand Up @@ -131,9 +133,34 @@ async def set_state(self, state: LightState) -> dict:
"""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
2 changes: 1 addition & 1 deletion kasa/tests/test_bulb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions kasa/tests/test_common_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from kasa import Device, LightState, Module
from kasa.tests.device_fixtures import (
bulb_iot,
bulb_smart,
dimmable_iot,
dimmer_iot,
lightstrip_iot,
Expand Down Expand Up @@ -40,6 +41,8 @@

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):
Expand Down Expand Up @@ -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."""
Expand All @@ -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
Expand All @@ -157,14 +183,14 @@ 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"

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
Expand Down
18 changes: 9 additions & 9 deletions kasa/tests/test_dimmer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@


@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
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down