Add LightEffectModule for dynamic light effects on SMART bulbs (#887)

Support the `light_effect` module which allows setting the effect to Off
or Party or Relax. Uses the new `Feature.Type.Choice`. Does not
currently allow editing of effects.
This commit is contained in:
Steven B
2024-05-02 15:31:12 +01:00
committed by GitHub
parent 5ef81f4669
commit 5b486074e2
8 changed files with 217 additions and 77 deletions

View File

@@ -15,6 +15,7 @@ from .firmware import Firmware
from .frostprotection import FrostProtectionModule
from .humidity import HumiditySensor
from .ledmodule import LedModule
from .lighteffectmodule import LightEffectModule
from .lighttransitionmodule import LightTransitionModule
from .reportmodule import ReportModule
from .temperature import TemperatureSensor
@@ -39,6 +40,7 @@ __all__ = [
"FanModule",
"Firmware",
"CloudModule",
"LightEffectModule",
"LightTransitionModule",
"ColorTemperatureModule",
"ColorModule",

View File

@@ -0,0 +1,112 @@
"""Module for light effects."""
from __future__ import annotations
import base64
import copy
from typing import TYPE_CHECKING, Any
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
from ..smartdevice import SmartDevice
class LightEffectModule(SmartModule):
"""Implementation of dynamic light effects."""
REQUIRED_COMPONENT = "light_effect"
QUERY_GETTER_NAME = "get_dynamic_light_effect_rules"
AVAILABLE_BULB_EFFECTS = {
"L1": "Party",
"L2": "Relax",
}
LIGHT_EFFECTS_OFF = "Off"
def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module)
self._scenes_names_to_id: dict[str, str] = {}
def _initialize_features(self):
"""Initialize features."""
device = self._device
self._add_feature(
Feature(
device,
"Light effect",
container=self,
attribute_getter="effect",
attribute_setter="set_effect",
category=Feature.Category.Config,
type=Feature.Type.Choice,
choices_getter="effect_list",
)
)
def _initialize_effects(self) -> dict[str, dict[str, Any]]:
"""Return built-in effects."""
# Copy the effects so scene name updates do not update the underlying dict.
effects = copy.deepcopy(
{effect["id"]: effect for effect in self.data["rule_list"]}
)
for effect in effects.values():
if not effect["scene_name"]:
# 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()
self._scenes_names_to_id = {
effect["scene_name"]: effect["id"] for effect in effects.values()
}
return effects
@property
def effect_list(self) -> list[str] | None:
"""Return built-in effects list.
Example:
['Party', 'Relax', ...]
"""
effects = [self.LIGHT_EFFECTS_OFF]
effects.extend(
[effect["scene_name"] for effect in self._initialize_effects().values()]
)
return effects
@property
def effect(self) -> str:
"""Return effect name."""
# get_dynamic_light_effect_rules also has an enable property and current_rule_id
# property that could be used here as an alternative
if self._device._info["dynamic_light_effect_enable"]:
return self._initialize_effects()[
self._device._info["dynamic_light_effect_id"]
]["scene_name"]
return self.LIGHT_EFFECTS_OFF
async def set_effect(
self,
effect: str,
) -> None:
"""Set an effect for 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:
raise ValueError(
f"Cannot set light effect to {effect}, possible values "
f"are: {self.LIGHT_EFFECTS_OFF} "
f"{' '.join(self._scenes_names_to_id.keys())}"
)
enable = effect != self.LIGHT_EFFECTS_OFF
params: dict[str, bool | str] = {"enable": enable}
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)
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {self.QUERY_GETTER_NAME: {"start_index": 0}}

View File

@@ -40,11 +40,6 @@ if TYPE_CHECKING:
# same issue, homekit perhaps?
WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule]
AVAILABLE_BULB_EFFECTS = {
"L1": "Party",
"L2": "Relax",
}
# Device must go last as the other interfaces also inherit Device
# and python needs a consistent method resolution order.
@@ -683,44 +678,6 @@ class SmartDevice(Bulb, Fan, Device):
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).valid_temperature_range
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
return "dynamic_light_effect_enable" in self._info
@property
def effect(self) -> dict:
"""Return effect state.
This follows the format used by SmartLightStrip.
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
# If no effect is active, dynamic_light_effect_id does not appear in info
current_effect = self._info.get("dynamic_light_effect_id", "")
data = {
"brightness": self.brightness,
"enable": current_effect != "",
"id": current_effect,
"name": AVAILABLE_BULB_EFFECTS.get(current_effect, ""),
}
return data
@property
def effect_list(self) -> list[str] | None:
"""Return built-in effects list.
Example:
['Party', 'Relax', ...]
"""
return list(AVAILABLE_BULB_EFFECTS.keys()) if self.has_effects else None
@property
def hsv(self) -> HSV:
"""Return the current HSV state of the bulb.
@@ -807,17 +764,12 @@ class SmartDevice(Bulb, Fan, Device):
brightness
)
async def set_effect(
self,
effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None:
"""Set an effect on the device."""
raise NotImplementedError()
@property
def presets(self) -> list[BulbPreset]:
"""Return a list of available bulb setting presets."""
return []
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
return "LightEffectModule" in self.modules