FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Fix changing brightness when effect is active by rytilahti · Pull Request #1019 · python-kasa/python-kasa · GitHub

Repository navigation

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

Filter by extension

Filter by extension .py  (10) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
3 changes: 3 additions & 0 deletions kasa/module.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ class Module(ABC):
"LightTransition"
)
ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode")
SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName(
"LightEffect"
)
TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName(
"TemperatureSensor"
)
Expand Down
25 changes: 25 additions & 0 deletions kasa/smart/effects.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,33 @@

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import cast

from ..interfaces.lighteffect import LightEffect as LightEffectInterface


class SmartLightEffect(LightEffectInterface, ABC):
Comment thread
rytilahti marked this conversation as resolved.
"""Abstract interface for smart light effects.

This interface extends lighteffect interface to add brightness controls.
"""

@abstractmethod
async def set_brightness(self, brightness: int, *, transition: int | None = None):
"""Set effect brightness."""

@property
@abstractmethod
def brightness(self) -> int:
"""Return effect brightness."""

@property
@abstractmethod
def is_active(self) -> bool:
"""Return True if effect is active."""


EFFECT_AURORA = {
"custom": 0,
"id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP",
Expand Down
2 changes: 2 additions & 0 deletions kasa/smart/modules/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Modules for SMART devices."""

from ..effects import SmartLightEffect
from .alarm import Alarm
from .autooff import AutoOff
from .batterysensor import BatterySensor
Expand Down Expand Up @@ -54,4 +55,5 @@
"WaterleakSensor",
"ContactSensor",
"FrostProtection",
"SmartLightEffect",
]
15 changes: 14 additions & 1 deletion kasa/smart/modules/brightness.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from ...feature import Feature
from ..smartmodule import SmartModule
from ..smartmodule import Module, SmartModule

BRIGHTNESS_MIN = 0
BRIGHTNESS_MAX = 100
Expand Down Expand Up @@ -42,6 +42,12 @@ def query(self) -> dict:
@property
def brightness(self):
"""Return current brightness."""
# If the device supports effects and one is active, use its brightness
if (
light_effect := self._device.modules.get(Module.SmartLightEffect)
) is not None and light_effect.is_active:
return light_effect.brightness

return self.data["brightness"]

async def set_brightness(self, brightness: int, *, transition: int | None = None):
Expand All @@ -59,6 +65,13 @@ async def set_brightness(self, brightness: int, *, transition: int | None = None

if brightness == 0:
return await self._device.turn_off()

# If the device supports effects and one is active, we adjust its brightness
if (
light_effect := self._device.modules.get(Module.SmartLightEffect)
) is not None and light_effect.is_active:
return await light_effect.set_brightness(brightness)

return await self.call("set_device_info", {"brightness": brightness})

async def _check_supported(self):
Expand Down
76 changes: 70 additions & 6 deletions kasa/smart/modules/lighteffect.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
from __future__ import annotations

import base64
import binascii
import contextlib
import copy
from typing import Any

from ...interfaces.lighteffect import LightEffect as LightEffectInterface
from ..smartmodule import SmartModule
from ..effects import SmartLightEffect
from ..smartmodule import Module, SmartModule


class LightEffect(SmartModule, LightEffectInterface):
class LightEffect(SmartModule, SmartLightEffect):
"""Implementation of dynamic light effects."""

REQUIRED_COMPONENT = "light_effect"
Expand All @@ -36,8 +38,11 @@ def _post_update_hook(self) -> None:
# If the name has not been edited scene_name will be an empty string
effect["scene_name"] = self.AVAILABLE_BULB_EFFECTS[effect["id"]]
else:
# Otherwise it will be b64 encoded
effect["scene_name"] = base64.b64decode(effect["scene_name"]).decode()
# Otherwise it might be b64 encoded or raw string
with contextlib.suppress(binascii.Error):
effect["scene_name"] = base64.b64decode(
effect["scene_name"]
).decode()

self._effect_state_list = effects
self._effect_list = [self.LIGHT_EFFECTS_OFF]
Expand Down Expand Up @@ -77,6 +82,8 @@ async def set_effect(
) -> None:
"""Set an effect for the device.

Calling this will modify the brightness of the effect on the device.

The device doesn't store an active effect while not enabled so store locally.
"""
if effect != self.LIGHT_EFFECTS_OFF and effect not in self._scenes_names_to_id:
Expand All @@ -90,7 +97,64 @@ async def set_effect(
if enable:
effect_id = self._scenes_names_to_id[effect]
params["id"] = effect_id
return await self.call("set_dynamic_light_effect_rule_enable", params)

# We set the wanted brightness before activating the effect
brightness_module = self._device.modules[Module.Brightness]
brightness = (
brightness if brightness is not None else brightness_module.brightness
)
await self.set_brightness(brightness, effect_id=effect_id)

await self.call("set_dynamic_light_effect_rule_enable", params)

@property
def is_active(self) -> bool:
"""Return True if effect is active."""
return bool(self._device._info["dynamic_light_effect_enable"])

def _get_effect_data(self, effect_id: str | None = None) -> dict[str, Any]:
"""Return effect data for the *effect_id*.

If *effect_id* is None, return the data for active effect.
"""
if effect_id is None:
effect_id = self.data["current_rule_id"]

return self._effect_state_list[effect_id]

@property
def brightness(self) -> int:
"""Return effect brightness."""
first_color_status = self._get_effect_data()["color_status_list"][0]
brightness = first_color_status[0]

return brightness

async def set_brightness(
self,
brightness: int,
*,
transition: int | None = None,
effect_id: str | None = None,
):
"""Set effect brightness."""
new_effect = self._get_effect_data(effect_id=effect_id).copy()

def _replace_brightness(data, new_brightness):
"""Replace brightness.

The first element is the brightness, the rest are unknown.
[[33, 0, 0, 2700], [33, 321, 99, 0], [33, 196, 99, 0], .. ]
"""
return [new_brightness, data[1], data[2], data[3]]

new_color_status_list = [
_replace_brightness(state, brightness)
for state in new_effect["color_status_list"]
]
new_effect["color_status_list"] = new_color_status_list

return await self.call("edit_dynamic_light_effect_rule", new_effect)

async def set_custom_effect(
self,
Expand Down
46 changes: 38 additions & 8 deletions kasa/smart/modules/lightstripeffect.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@

from typing import TYPE_CHECKING

from ...interfaces.lighteffect import LightEffect as LightEffectInterface
from ..effects import EFFECT_MAPPING, EFFECT_NAMES
from ..smartmodule import SmartModule
from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect
from ..smartmodule import Module, SmartModule

if TYPE_CHECKING:
from ..smartdevice import SmartDevice


class LightStripEffect(SmartModule, LightEffectInterface):
class LightStripEffect(SmartModule, SmartLightEffect):
"""Implementation of dynamic light effects."""

REQUIRED_COMPONENT = "light_strip_lighting_effect"
Expand All @@ -22,6 +21,7 @@ def __init__(self, device: SmartDevice, module: str):
effect_list = [self.LIGHT_EFFECTS_OFF]
effect_list.extend(EFFECT_NAMES)
self._effect_list = effect_list
self._effect_mapping = EFFECT_MAPPING

@property
def name(self) -> str:
Expand Down Expand Up @@ -53,6 +53,28 @@ def effect(self) -> str:
return name
return self.LIGHT_EFFECTS_OFF

@property
def is_active(self) -> bool:
"""Return if effect is active."""
eff = self.data["lighting_effect"]
# softAP has enable=1, but brightness 0 which fails on tests
return bool(eff["enable"]) and eff["name"] in self._effect_list

@property
def brightness(self) -> int:
"""Return effect brightness."""
eff = self.data["lighting_effect"]
return eff["brightness"]

async def set_brightness(self, brightness: int, *, transition: int | None = None):
"""Set effect brightness."""
if brightness <= 0:
return await self.set_effect(self.LIGHT_EFFECTS_OFF)

# Need to pass bAdjusted to keep the existing effect running
eff = {"brightness": brightness, "bAdjusted": True}
return await self.set_custom_effect(eff)
Comment thread
rytilahti marked this conversation as resolved.

@property
def effect_list(self) -> list[str]:
"""Return built-in effects list.
Expand Down Expand Up @@ -81,16 +103,24 @@ async def set_effect(
:param int brightness: The wanted brightness
:param int transition: The wanted transition time
"""
brightness_module = self._device.modules[Module.Brightness]
if effect == self.LIGHT_EFFECTS_OFF:
effect_dict = dict(self.data["lighting_effect"])
effect_dict["enable"] = 0
elif effect not in EFFECT_MAPPING:
state = self._device.modules[Module.Light].state
await self._device.modules[Module.Light].set_state(state)
return

if effect not in self._effect_mapping:
raise ValueError(f"The effect {effect} is not a built in effect.")
else:
effect_dict = EFFECT_MAPPING[effect]
effect_dict = self._effect_mapping[effect]

# Use explicitly given brightness
if brightness is not None:
effect_dict["brightness"] = brightness
# Fall back to brightness reported by the brightness module
elif brightness_module.brightness:
effect_dict["brightness"] = brightness_module.brightness

if transition is not None:
effect_dict["transition"] = transition

Expand Down
18 changes: 17 additions & 1 deletion kasa/tests/fakeprotocol_smart.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -250,18 +250,31 @@ def _set_dynamic_light_effect(self, info, params):
info["get_dynamic_light_effect_rules"]["enable"] = params["enable"]
if params["enable"]:
info["get_device_info"]["dynamic_light_effect_id"] = params["id"]
info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["enable"]
info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["id"]
Comment thread
rytilahti marked this conversation as resolved.
else:
if "dynamic_light_effect_id" in info["get_device_info"]:
del info["get_device_info"]["dynamic_light_effect_id"]
if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
del info["get_dynamic_light_effect_rules"]["current_rule_id"]

def _set_edit_dynamic_light_effect_rule(self, info, params):
"""Edit dynamic light effect rule."""
rules = info["get_dynamic_light_effect_rules"]["rule_list"]
for rule in rules:
if rule["id"] == params["id"]:
rule.update(params)
return

raise Exception("Unable to find rule with id")

def _set_light_strip_effect(self, info, params):
"""Set or remove values as per the device behaviour."""
info["get_device_info"]["lighting_effect"]["enable"] = params["enable"]
info["get_device_info"]["lighting_effect"]["name"] = params["name"]
info["get_device_info"]["lighting_effect"]["id"] = params["id"]
# Brightness is not always available
if (brightness := params.get("brightness")) is not None:
info["get_device_info"]["lighting_effect"]["brightness"] = brightness
info["get_lighting_effect"] = copy.deepcopy(params)

def _set_led_info(self, info, params):
Expand Down Expand Up @@ -365,6 +378,9 @@ def _send_request(self, request_dict: dict):
elif method == "set_dynamic_light_effect_rule_enable":
self._set_dynamic_light_effect(info, params)
return {"error_code": 0}
elif method == "edit_dynamic_light_effect_rule":
self._set_edit_dynamic_light_effect_rule(info, params)
return {"error_code": 0}
elif method == "set_lighting_effect":
self._set_light_strip_effect(info, params)
return {"error_code": 0}
Expand Down
42 changes: 42 additions & 0 deletions kasa/tests/smart/modules/test_light_effect.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,45 @@ async def test_light_effect(dev: Device, mocker: MockerFixture):

with pytest.raises(ValueError):
await light_effect.set_effect("foobar")


@light_effect
@pytest.mark.parametrize("effect_active", [True, False])
async def test_light_effect_brightness(
dev: Device, effect_active: bool, mocker: MockerFixture
):
"""Test that light module uses light_effect for brightness when active."""
light_module = dev.modules[Module.Light]

light_effect = dev.modules[Module.SmartLightEffect]
light_effect_set_brightness = mocker.spy(light_effect, "set_brightness")
mock_light_effect_call = mocker.patch.object(light_effect, "call")

brightness = dev.modules[Module.Brightness]
brightness_set_brightness = mocker.spy(brightness, "set_brightness")
mock_brightness_call = mocker.patch.object(brightness, "call")

mocker.patch.object(
type(light_effect),
"is_active",
new_callable=mocker.PropertyMock,
return_value=effect_active,
)
if effect_active: # Set the rule L1 active for testing
light_effect.data["current_rule_id"] = "L1"

await light_module.set_brightness(10)

if effect_active:
assert light_effect.is_active
assert light_effect.brightness == dev.brightness

light_effect_set_brightness.assert_called_with(10)
mock_light_effect_call.assert_called_with(
"edit_dynamic_light_effect_rule", mocker.ANY
)
else:
assert not light_effect.is_active

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

Back | FazBrowse Home | New Git URL