Add LightEffect module for smart light strips (#918)

Implements the `light_strip_lighting_effect` components for
`smart` devices. Uses a new list of effects captured from a L900 which
are similar to the `iot` effects but include some additional properties
and a few extra effects.

Assumes that a device only implements `light_strip_lighting_effect` or
`light_effect` but not both.
This commit is contained in:
Steven B 2024-05-15 06:16:57 +01:00 committed by GitHub
parent 67b5d7de83
commit 133a839f22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 572 additions and 19 deletions

View File

@ -911,11 +911,12 @@ async def effect(dev: Device, ctx, effect):
echo("Device does not support effects") echo("Device does not support effects")
return return
if effect is None: if effect is None:
raise click.BadArgumentUsage( echo(
"Setting an effect requires a named built-in effect: " f"Light effect: {light_effect.effect}\n"
+ f"{light_effect.effect_list}", + f"Available Effects: {light_effect.effect_list}"
ctx,
) )
return light_effect.effect
if effect not in light_effect.effect_list: if effect not in light_effect.effect_list:
raise click.BadArgumentUsage( raise click.BadArgumentUsage(
f"Effect must be one of: {light_effect.effect_list}", ctx f"Effect must be one of: {light_effect.effect_list}", ctx

View File

@ -21,13 +21,11 @@ class LightEffect(IotModule, LightEffectInterface):
'id': '', 'id': '',
'name': ''} 'name': ''}
""" """
if ( eff = self.data["lighting_effect_state"]
(state := self.data.get("lighting_effect_state")) name = eff["name"]
and state.get("enable") if eff["enable"]:
and (name := state.get("name"))
and name in EFFECT_NAMES_V1
):
return name return name
return self.LIGHT_EFFECTS_OFF return self.LIGHT_EFFECTS_OFF
@property @property
@ -67,6 +65,7 @@ class LightEffect(IotModule, LightEffectInterface):
raise ValueError(f"The effect {effect} is not a built in effect.") raise ValueError(f"The effect {effect} is not a built in effect.")
else: else:
effect_dict = EFFECT_MAPPING_V1[effect] effect_dict = EFFECT_MAPPING_V1[effect]
if brightness is not None: if brightness is not None:
effect_dict["brightness"] = brightness effect_dict["brightness"] = brightness
if transition is not None: if transition is not None:

429
kasa/smart/effects.py Normal file
View File

@ -0,0 +1,429 @@
"""Module for light strip light effects."""
from __future__ import annotations
from typing import cast
EFFECT_AURORA = {
"custom": 0,
"id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP",
"brightness": 100,
"name": "Aurora",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [
[120, 100, 100],
[240, 100, 100],
[260, 100, 100],
[280, 100, 100],
],
"type": "sequence",
"duration": 0,
"transition": 1500,
"direction": 4,
"spread": 7,
"repeat_times": 0,
"sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]],
}
EFFECT_BUBBLING_CAULDRON = {
"custom": 0,
"id": "TapoStrip_6DlumDwO2NdfHppy50vJtu",
"brightness": 100,
"name": "Bubbling Cauldron",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[100, 100, 100], [270, 100, 100]],
"type": "random",
"hue_range": [100, 270],
"saturation_range": [80, 100],
"brightness_range": [50, 100],
"duration": 0,
"transition": 200,
"init_states": [[270, 100, 100]],
"fadeoff": 1000,
"random_seed": 24,
"backgrounds": [[270, 40, 50]],
}
EFFECT_CANDY_CANE = {
"custom": 0,
"id": "TapoStrip_6Dy0Nc45vlhFPEzG021Pe9",
"brightness": 100,
"name": "Candy Cane",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[0, 0, 100], [0, 81, 100]],
"type": "sequence",
"duration": 700,
"transition": 500,
"direction": 1,
"spread": 1,
"repeat_times": 0,
"sequence": [
[0, 0, 100],
[0, 0, 100],
[360, 81, 100],
[0, 0, 100],
[0, 0, 100],
[360, 81, 100],
[360, 81, 100],
[0, 0, 100],
[0, 0, 100],
[360, 81, 100],
[360, 81, 100],
[360, 81, 100],
[360, 81, 100],
[0, 0, 100],
[0, 0, 100],
[360, 81, 100],
],
}
EFFECT_CHRISTMAS = {
"custom": 0,
"id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh",
"brightness": 100,
"name": "Christmas",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[136, 98, 100], [350, 97, 100]],
"type": "random",
"hue_range": [136, 146],
"saturation_range": [90, 100],
"brightness_range": [50, 100],
"duration": 5000,
"transition": 0,
"init_states": [[136, 0, 100]],
"fadeoff": 2000,
"random_seed": 100,
"backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]],
}
EFFECT_FLICKER = {
"custom": 0,
"id": "TapoStrip_4HVKmMc6vEzjm36jXaGwMs",
"brightness": 100,
"name": "Flicker",
"enable": 1,
"segments": [1],
"expansion_strategy": 1,
"display_colors": [[30, 81, 100], [40, 100, 100]],
"type": "random",
"hue_range": [30, 40],
"saturation_range": [100, 100],
"brightness_range": [50, 100],
"duration": 0,
"transition": 0,
"transition_range": [375, 500],
"init_states": [[30, 81, 80]],
}
EFFECT_GRANDMAS_CHRISTMAS_LIGHTS = {
"custom": 0,
"id": "TapoStrip_3Gk6CmXOXbjCiwz9iD543C",
"brightness": 100,
"name": "Grandma's Christmas Lights",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[30, 100, 100], [240, 100, 100], [130, 100, 100], [0, 100, 100]],
"type": "sequence",
"duration": 5000,
"transition": 100,
"direction": 1,
"spread": 1,
"repeat_times": 0,
"sequence": [
[30, 100, 100],
[30, 0, 0],
[30, 0, 0],
[240, 100, 100],
[240, 0, 0],
[240, 0, 0],
[240, 0, 100],
[240, 0, 0],
[240, 0, 0],
[130, 100, 100],
[130, 0, 0],
[130, 0, 0],
[0, 100, 100],
[0, 0, 0],
[0, 0, 0],
],
}
EFFECT_HANUKKAH = {
"custom": 0,
"id": "TapoStrip_2YTk4wramLKv5XZ9KFDVYm",
"brightness": 100,
"name": "Hanukkah",
"enable": 1,
"segments": [1],
"expansion_strategy": 1,
"display_colors": [[200, 100, 100]],
"type": "random",
"hue_range": [200, 210],
"saturation_range": [0, 100],
"brightness_range": [50, 100],
"duration": 1500,
"transition": 0,
"transition_range": [400, 500],
"init_states": [[35, 81, 80]],
}
EFFECT_HAUNTED_MANSION = {
"custom": 0,
"id": "TapoStrip_4rJ6JwC7I9st3tQ8j4lwlI",
"brightness": 100,
"name": "Haunted Mansion",
"enable": 1,
"segments": [80],
"expansion_strategy": 2,
"display_colors": [[44, 9, 100]],
"type": "random",
"hue_range": [45, 45],
"saturation_range": [10, 10],
"brightness_range": [0, 80],
"duration": 0,
"transition": 0,
"transition_range": [50, 1500],
"init_states": [[45, 10, 100]],
"fadeoff": 200,
"random_seed": 1,
"backgrounds": [[45, 10, 100]],
}
EFFECT_ICICLE = {
"custom": 0,
"id": "TapoStrip_7UcYLeJbiaxVIXCxr21tpx",
"brightness": 100,
"name": "Icicle",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[190, 100, 100]],
"type": "sequence",
"duration": 0,
"transition": 400,
"direction": 4,
"spread": 3,
"repeat_times": 0,
"sequence": [
[190, 100, 70],
[190, 100, 70],
[190, 30, 50],
[190, 100, 70],
[190, 100, 70],
],
}
EFFECT_LIGHTNING = {
"custom": 0,
"id": "TapoStrip_7OGzfSfnOdhoO2ri4gOHWn",
"brightness": 100,
"name": "Lightning",
"enable": 1,
"segments": [7],
"expansion_strategy": 1,
"display_colors": [[210, 9, 100], [200, 50, 100], [200, 100, 100]],
"type": "random",
"hue_range": [240, 240],
"saturation_range": [10, 11],
"brightness_range": [90, 100],
"duration": 0,
"transition": 50,
"init_states": [[240, 30, 100]],
"fadeoff": 150,
"random_seed": 50,
"backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]],
}
EFFECT_OCEAN = {
"custom": 0,
"id": "TapoStrip_0fOleCdwSgR0nfjkReeYfw",
"brightness": 100,
"name": "Ocean",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[198, 84, 100]],
"type": "sequence",
"duration": 0,
"transition": 2000,
"direction": 3,
"spread": 16,
"repeat_times": 0,
"sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]],
}
EFFECT_RAINBOW = {
"custom": 0,
"id": "TapoStrip_7CC5y4lsL8pETYvmz7UOpQ",
"brightness": 100,
"name": "Rainbow",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [
[0, 100, 100],
[100, 100, 100],
[200, 100, 100],
[300, 100, 100],
],
"type": "sequence",
"duration": 0,
"transition": 1500,
"direction": 1,
"spread": 12,
"repeat_times": 0,
"sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]],
}
EFFECT_RAINDROP = {
"custom": 0,
"id": "TapoStrip_1t2nWlTBkV8KXBZ0TWvBjs",
"brightness": 100,
"name": "Raindrop",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[200, 9, 100], [200, 19, 100]],
"type": "random",
"hue_range": [200, 200],
"saturation_range": [10, 20],
"brightness_range": [10, 30],
"duration": 0,
"transition": 1000,
"init_states": [[200, 40, 100]],
"fadeoff": 1000,
"random_seed": 24,
"backgrounds": [[200, 40, 0]],
}
EFFECT_SPRING = {
"custom": 0,
"id": "TapoStrip_1nL6GqZ5soOxj71YDJOlZL",
"brightness": 100,
"name": "Spring",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[0, 30, 100], [130, 100, 100]],
"type": "random",
"hue_range": [0, 90],
"saturation_range": [30, 100],
"brightness_range": [90, 100],
"duration": 600,
"transition": 0,
"transition_range": [2000, 6000],
"init_states": [[80, 30, 100]],
"fadeoff": 1000,
"random_seed": 20,
"backgrounds": [[130, 100, 40]],
}
EFFECT_SUNRISE = {
"custom": 0,
"id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi",
"brightness": 100,
"name": "Sunrise",
"enable": 1,
"segments": [0],
"expansion_strategy": 2,
"display_colors": [[0, 0, 100], [30, 95, 100], [0, 100, 100]],
"type": "pulse",
"duration": 600,
"transition": 60000,
"direction": 1,
"spread": 1,
"repeat_times": 1,
"run_time": 0,
"sequence": [
[0, 100, 5],
[0, 100, 5],
[10, 100, 6],
[15, 100, 7],
[20, 100, 8],
[20, 100, 10],
[30, 100, 12],
[30, 95, 15],
[30, 90, 20],
[30, 80, 25],
[30, 75, 30],
[30, 70, 40],
[30, 60, 50],
[30, 50, 60],
[30, 20, 70],
[30, 0, 100],
],
"trans_sequence": [],
}
EFFECT_SUNSET = {
"custom": 0,
"id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL",
"brightness": 100,
"name": "Sunset",
"enable": 1,
"segments": [0],
"expansion_strategy": 2,
"display_colors": [[0, 100, 100], [30, 95, 100], [0, 0, 100]],
"type": "pulse",
"duration": 600,
"transition": 60000,
"direction": 1,
"spread": 1,
"repeat_times": 1,
"run_time": 0,
"sequence": [
[30, 0, 100],
[30, 20, 100],
[30, 50, 99],
[30, 60, 98],
[30, 70, 97],
[30, 75, 95],
[30, 80, 93],
[30, 90, 90],
[30, 95, 85],
[30, 100, 80],
[20, 100, 70],
[20, 100, 60],
[15, 100, 50],
[10, 100, 40],
[0, 100, 30],
[0, 100, 0],
],
"trans_sequence": [],
}
EFFECT_VALENTINES = {
"custom": 0,
"id": "TapoStrip_2q1Vio9sSjHmaC7JS9d30l",
"brightness": 100,
"name": "Valentines",
"enable": 1,
"segments": [0],
"expansion_strategy": 1,
"display_colors": [[339, 19, 100], [19, 50, 100], [0, 100, 100], [339, 40, 100]],
"type": "random",
"hue_range": [340, 340],
"saturation_range": [30, 40],
"brightness_range": [90, 100],
"duration": 600,
"transition": 2000,
"init_states": [[340, 30, 100]],
"fadeoff": 3000,
"random_seed": 100,
"backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]],
}
EFFECTS_LIST = [
EFFECT_AURORA,
EFFECT_BUBBLING_CAULDRON,
EFFECT_CANDY_CANE,
EFFECT_CHRISTMAS,
EFFECT_FLICKER,
EFFECT_GRANDMAS_CHRISTMAS_LIGHTS,
EFFECT_HANUKKAH,
EFFECT_HAUNTED_MANSION,
EFFECT_ICICLE,
EFFECT_LIGHTNING,
EFFECT_OCEAN,
EFFECT_RAINBOW,
EFFECT_RAINDROP,
EFFECT_SPRING,
EFFECT_SUNRISE,
EFFECT_SUNSET,
EFFECT_VALENTINES,
]
EFFECT_NAMES: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST]
EFFECT_MAPPING = {effect["name"]: effect for effect in EFFECTS_LIST}

View File

@ -18,6 +18,7 @@ from .humiditysensor import HumiditySensor
from .led import Led from .led import Led
from .light import Light from .light import Light
from .lighteffect import LightEffect from .lighteffect import LightEffect
from .lightstripeffect import LightStripEffect
from .lighttransition import LightTransition from .lighttransition import LightTransition
from .reportmode import ReportMode from .reportmode import ReportMode
from .temperaturecontrol import TemperatureControl from .temperaturecontrol import TemperatureControl
@ -44,6 +45,7 @@ __all__ = [
"Cloud", "Cloud",
"Light", "Light",
"LightEffect", "LightEffect",
"LightStripEffect",
"LightTransition", "LightTransition",
"ColorTemperature", "ColorTemperature",
"Color", "Color",

View File

@ -0,0 +1,109 @@
"""Module for light effects."""
from __future__ import annotations
from ...interfaces.lighteffect import LightEffect as LightEffectInterface
from ..effects import EFFECT_MAPPING, EFFECT_NAMES
from ..smartmodule import SmartModule
class LightStripEffect(SmartModule, LightEffectInterface):
"""Implementation of dynamic light effects."""
REQUIRED_COMPONENT = "light_strip_lighting_effect"
@property
def name(self) -> str:
"""Name of the module.
By default smart modules are keyed in the module mapping by class name.
The name is overriden here as this module implements the same common interface
as the bulb light_effect and the assumption is a device only supports one
or the other.
"""
return "LightEffect"
@property
def effect(self) -> str:
"""Return effect state.
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
eff = self.data["lighting_effect"]
name = eff["name"]
if eff["enable"]:
return name
return self.LIGHT_EFFECTS_OFF
@property
def effect_list(self) -> list[str]:
"""Return built-in effects list.
Example:
['Aurora', 'Bubbling Cauldron', ...]
"""
effect_list = [self.LIGHT_EFFECTS_OFF]
effect_list.extend(EFFECT_NAMES)
return effect_list
async def set_effect(
self,
effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None:
"""Set an effect on the device.
If brightness or transition is defined,
its value will be used instead of the effect-specific default.
See :meth:`effect_list` for available effects,
or use :meth:`set_custom_effect` for custom effects.
:param str effect: The effect to set
:param int brightness: The wanted brightness
:param int transition: The wanted transition time
"""
if effect == self.LIGHT_EFFECTS_OFF:
effect_dict = dict(self.data["lighting_effect"])
effect_dict["enable"] = 0
elif effect not in EFFECT_MAPPING:
raise ValueError(f"The effect {effect} is not a built in effect.")
else:
effect_dict = EFFECT_MAPPING[effect]
if brightness is not None:
effect_dict["brightness"] = brightness
if transition is not None:
effect_dict["transition"] = transition
await self.set_custom_effect(effect_dict)
async def set_custom_effect(
self,
effect_dict: dict,
) -> None:
"""Set a custom effect on the device.
:param str effect_dict: The custom effect dict to set
"""
return await self.call(
"set_lighting_effect",
effect_dict,
)
@property
def has_custom_effects(self) -> bool:
"""Return True if the device supports setting custom effects."""
return True
def query(self):
"""Return the base query."""
return {}

View File

@ -176,7 +176,7 @@ class FakeSmartTransport(BaseTransport):
"Method %s not implemented for children" % child_method "Method %s not implemented for children" % child_method
) )
def _set_light_effect(self, info, params): def _set_dynamic_light_effect(self, info, params):
"""Set or remove values as per the device behaviour.""" """Set or remove values as per the device behaviour."""
info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"] info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"]
info["get_dynamic_light_effect_rules"]["enable"] = params["enable"] info["get_dynamic_light_effect_rules"]["enable"] = params["enable"]
@ -189,6 +189,13 @@ class FakeSmartTransport(BaseTransport):
if "current_rule_id" in info["get_dynamic_light_effect_rules"]: if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
del info["get_dynamic_light_effect_rules"]["current_rule_id"] del info["get_dynamic_light_effect_rules"]["current_rule_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"]
info["get_lighting_effect"] = copy.deepcopy(params)
def _set_led_info(self, info, params): def _set_led_info(self, info, params):
"""Set or remove values as per the device behaviour.""" """Set or remove values as per the device behaviour."""
info["get_led_info"]["led_status"] = params["led_rule"] != "never" info["get_led_info"]["led_status"] = params["led_rule"] != "never"
@ -244,7 +251,10 @@ class FakeSmartTransport(BaseTransport):
elif method in ["set_qs_info", "fw_download"]: elif method in ["set_qs_info", "fw_download"]:
return {"error_code": 0} return {"error_code": 0}
elif method == "set_dynamic_light_effect_rule_enable": elif method == "set_dynamic_light_effect_rule_enable":
self._set_light_effect(info, params) self._set_dynamic_light_effect(info, params)
return {"error_code": 0}
elif method == "set_lighting_effect":
self._set_light_strip_effect(info, params)
return {"error_code": 0} return {"error_code": 0}
elif method == "set_led_info": elif method == "set_led_info":
self._set_led_info(info, params) self._set_led_info(info, params)

View File

@ -390,12 +390,8 @@ async def test_light_effect(dev: Device, runner: CliRunner):
assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF
res = await runner.invoke(effect, obj=dev) res = await runner.invoke(effect, obj=dev)
msg = ( assert f"Light effect: {light_effect.effect}" in res.output
"Setting an effect requires a named built-in effect: " assert res.exit_code == 0
+ f"{light_effect.effect_list}"
)
assert msg in res.output
assert res.exit_code == 2
res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev) res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev)
assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output

View File

@ -19,7 +19,14 @@ led = parametrize_combine([led_smart, plug_iot])
light_effect_smart = parametrize( light_effect_smart = parametrize(
"has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"}
) )
light_effect = parametrize_combine([light_effect_smart, lightstrip_iot]) light_strip_effect_smart = parametrize(
"has light strip effect smart",
component_filter="light_strip_lighting_effect",
protocol_filter={"SMART"},
)
light_effect = parametrize_combine(
[light_effect_smart, light_strip_effect_smart, lightstrip_iot]
)
dimmable_smart = parametrize( dimmable_smart = parametrize(
"dimmable smart", component_filter="brightness", protocol_filter={"SMART"} "dimmable smart", component_filter="brightness", protocol_filter={"SMART"}