Skip to content
Open
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
9 changes: 8 additions & 1 deletion kasa/smart/modules/brightness.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,14 @@ async def set_brightness(
) is not None and light_effect.is_active:
return await light_effect.set_brightness(brightness)

return await self.call("set_device_info", {"brightness": brightness})
# Only re-assert device_on when the device is already on. This fixes a
# "zombie state" on some devices (e.g. Tapo L900-5) where set_device_info
# silently no-ops the LEDs without an explicit power state, while still
# allowing brightness to be changed on an off device without turning it on.
params: dict = {"brightness": brightness}
if self._device.is_on:
params["device_on"] = True
return await self.call("set_device_info", params)

async def _check_supported(self) -> bool:
"""Additional check to see if the module is supported by the device."""
Expand Down
3 changes: 3 additions & 0 deletions kasa/smart/modules/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,8 @@ async def set_hsv(
# The device errors on invalid brightness values.
if value is not None:
request_payload["brightness"] = value
# Only re-assert device_on when the device is already on; see Brightness.
if self._device.is_on:
request_payload["device_on"] = True

return await self.call("set_device_info", {**request_payload})
5 changes: 4 additions & 1 deletion kasa/smart/modules/colortemperature.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ async def set_color_temp(self, temp: int, *, brightness: int | None = None) -> d
*valid_temperature_range, temp
)
)
params = {"color_temp": temp}
params: dict = {"color_temp": temp}
if brightness:
params["brightness"] = brightness
# Only re-assert device_on when the device is already on; see Brightness.
if self._device.is_on:
params["device_on"] = True
return await self.call("set_device_info", params)

async def _check_supported(self) -> bool:
Expand Down
102 changes: 102 additions & 0 deletions tests/smart/modules/test_light_device_on_payload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Tests that set_brightness/set_color_temp/set_hsv inject device_on correctly.

Regression coverage for two interacting concerns:

* Tapo L900-5 "zombie state" — device reports on but LEDs stay off unless
device_on is re-asserted in the set_device_info payload.
* Issue #1532 — users want to change brightness on an already-off device
without turning it on as a side-effect.

The compromise: re-assert device_on=True only when the device is currently on,
otherwise leave it out so an off device stays off.
"""

from __future__ import annotations

import pytest
from pytest_mock import MockerFixture

from kasa import Module
from kasa.smart import SmartDevice

from ...device_fixtures import get_parent_and_child_modules, parametrize

brightness_smart = parametrize(
"has brightness", component_filter="brightness", protocol_filter={"SMART"}
)
color_smart = parametrize(
"has color", component_filter="color", protocol_filter={"SMART"}
)
color_temp_smart = parametrize(
"has color temperature",
component_filter="color_temperature",
protocol_filter={"SMART"},
)


@brightness_smart
@pytest.mark.parametrize("is_on", [True, False])
async def test_set_brightness_device_on_payload(
dev: SmartDevice, is_on: bool, mocker: MockerFixture
) -> None:
brightness = next(get_parent_and_child_modules(dev, Module.Brightness))
owner = brightness._device
# Bypass any active light effect so set_brightness hits set_device_info.
if (effect := owner.modules.get(Module.SmartLightEffect)) is not None:
mocker.patch.object(
type(effect),
"is_active",
new_callable=mocker.PropertyMock,
return_value=False,
)

owner._info["device_on"] = is_on
mock_call = mocker.patch.object(brightness, "call")

await brightness.set_brightness(50)

expected: dict = {"brightness": 50}
if is_on:
expected["device_on"] = True
mock_call.assert_called_once_with("set_device_info", expected)


@color_temp_smart
@pytest.mark.parametrize("is_on", [True, False])
async def test_set_color_temp_device_on_payload(
dev: SmartDevice, is_on: bool, mocker: MockerFixture
) -> None:
ct = next(get_parent_and_child_modules(dev, Module.ColorTemperature), None)
if ct is None:
pytest.skip("Device has color_temperature component but no module enabled")
if ct.valid_temperature_range.min == ct.valid_temperature_range.max:
pytest.skip("Device exposes color_temperature but no usable range")

ct._device._info["device_on"] = is_on
mock_call = mocker.patch.object(ct, "call")

temp = ct.valid_temperature_range.min
await ct.set_color_temp(temp)

expected: dict = {"color_temp": temp}
if is_on:
expected["device_on"] = True
mock_call.assert_called_once_with("set_device_info", expected)


@color_smart
@pytest.mark.parametrize("is_on", [True, False])
async def test_set_hsv_device_on_payload(
dev: SmartDevice, is_on: bool, mocker: MockerFixture
) -> None:
color = next(get_parent_and_child_modules(dev, Module.Color))

color._device._info["device_on"] = is_on
mock_call = mocker.patch.object(color, "call")

await color.set_hsv(120, 50)

expected: dict = {"color_temp": 0, "hue": 120, "saturation": 50}
if is_on:
expected["device_on"] = True
mock_call.assert_called_once_with("set_device_info", expected)
13 changes: 12 additions & 1 deletion tests/smart/modules/test_light_effect.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ async def test_light_effect_brightness(
if effect_active: # Set the rule L1 active for testing
light_effect.data["current_rule_id"] = "L1"

# Force is_on=True so the asserted payload is deterministic across fixtures
# (some fixtures' get_device_info has device_on: false).
mocker.patch.object(
type(dev),
"is_on",
new_callable=mocker.PropertyMock,
return_value=True,
)

await light_module.set_brightness(10)

if effect_active:
Expand All @@ -81,4 +90,6 @@ async def test_light_effect_brightness(
assert not light_effect.is_active

brightness_set_brightness.assert_called_with(10)
mock_brightness_call.assert_called_with("set_device_info", {"brightness": 10})
mock_brightness_call.assert_called_with(
"set_device_info", {"brightness": 10, "device_on": True}
)
13 changes: 12 additions & 1 deletion tests/smart/modules/test_light_strip_effect.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ async def test_light_effect_brightness(
return_value=effect_active,
)

# Force is_on=True so the asserted payload is deterministic across fixtures
# (some fixtures' get_device_info has device_on: false).
mocker.patch.object(
type(dev),
"is_on",
new_callable=mocker.PropertyMock,
return_value=True,
)

await light_module.set_brightness(10)

if effect_active:
Expand All @@ -96,4 +105,6 @@ async def test_light_effect_brightness(
assert not light_effect.is_active

brightness_set_brightness.assert_called_with(10)
mock_brightness_call.assert_called_with("set_device_info", {"brightness": 10})
mock_brightness_call.assert_called_with(
"set_device_info", {"brightness": 10, "device_on": True}
)
Loading