8000 Add LightEffectModule for dynamic light effects on SMART bulbs (#887) · python-kasa/python-kasa@5b48607 · GitHub
[go: up one dir, main page]

Skip to content

Commit 5b48607

Browse files
authored
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.
1 parent 5ef81f4 commit 5b48607

File tree

8 files changed

+217
-77
lines changed
  • smart
  • tests
  • 8 files changed

    +217
    -77
    lines changed

    kasa/cli.py

    Lines changed: 18 additions & 20 deletions
    Original file line numberDiff line numberDiff line change
    @@ -586,6 +586,7 @@ def _echo_features(
    586586
    title: str,
    587587
    category: Feature.Category | None = None,
    588588
    verbose: bool = False,
    589+
    indent: str = "\t",
    589590
    ):
    590591
    """Print out a listing of features and their values."""
    591592
    if category is not None:
    @@ -598,13 +599,13 @@ def _echo_features(
    598599
    echo(f"[bold]{title}[/bold]")
    599600
    for _, feat in features.items():
    600601
    try:
    601-
    echo(f"\t{feat}")
    602+
    echo(f"{indent}{feat}")
    602603
    if verbose:
    603-
    echo(f"\t\tType: {feat.type}")
    604-
    echo(f"\t\tCategory: {feat.category}")
    605-
    echo(f"\t\tIcon: {feat.icon}")
    604+
    echo(f"{indent}\tType: {feat.type}")
    605+
    echo(f"{indent}\tCategory: {feat.category}")
    606+
    echo(f"{indent}\tIcon: {feat.icon}")
    606607
    except Exception as ex:
    607-
    echo(f"\t{feat.name} ({feat.id}): got exception (%s)" % ex)
    608+
    echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]")
    608609

    609610

    610611
    def _echo_all_features(features, *, verbose=False, title_prefix=None):
    @@ -1219,22 +1220,15 @@ async def feature(dev: Device, child: str, name: str, value):
    12191220
    echo(f"Targeting child device {child}")
    12201221
    dev = dev.get_child_device(child)
    12211222
    if not name:
    1222-
    1223-
    def _print_features(dev):
    1224-
    for name, feat in dev.features.items():
    1225-
    try:
    1226-
    unit = f" {feat.unit}" if feat.unit else ""
    1227-
    echo(f"\t{feat.name} ({name}): {feat.value}{unit}")
    1228-
    except Exception as ex:
    1229-
    echo(f"\t{feat.name} ({name}): [red]{ex}[/red]")
    1230-
    1231-
    echo("[bold]== Features ==[/bold]")
    1232-
    _print_features(dev)
    1223+
    _echo_features(dev.features, "\n[bold]== Features ==[/bold]\n", indent="")
    12331224

    12341225
    if dev.children:
    12351226
    for child_dev in dev.children:
    1236-
    echo(f"[bold]== Child {child_dev.alias} ==")
    1237-
    _print_features(child_dev)
    1227+
    _echo_features(
    1228+
    child_dev.features,
    1229+
    f"\n[bold]== Child {child_dev.alias} ==\n",
    1230+
    indent="",
    1231+
    )
    12381232

    12391233
    return
    12401234

    @@ -1249,9 +1243,13 @@ def _print_features(dev):
    12491243
    echo(f"{feat.name} ({name}): {feat.value}{unit}")
    12501244
    return feat.value
    12511245

    1252-
    echo(f"Setting {name} to {value}")
    12531246
    value = ast.literal_eval(value)
    1254-
    return await dev.features[name].set_value(value)
    1247+
    echo(f"Changing {name} from {feat.value} to {value}")
    1248+
    response = await dev.features[name].set_value(value)
    1249+
    await dev.update()
    1250+
    echo(f"New state: {feat.value}")
    1251+
    1252+
    return response
    12551253

    12561254

    12571255
    if __name__ == "__main__":

    kasa/feature.py

    Lines changed: 7 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -174,9 +174,16 @@ async def set_value(self, value):
    174174
    def __repr__(self):
    175175
    try:
    176176
    value = self.value
    177+
    choices = self.choices
    177178
    except Exception as ex:
    178179
    return f"Unable to read value ({self.id}): {ex}"
    179180

    181+
    if self.type == Feature.Type.Choice:
    182+
    if not isinstance(choices, list) or value not in choices:
    183+
    return f"Value {value} is not a valid choice ({self.id}): {choices}"
    184+
    value = " ".join(
    185+
    [f"*{choice}*" if choice == value else choice for choice in choices]
    186+
    )
    180187
    if self.precision_hint is not None and value is not None:
    181188
    value = round(self.value, self.precision_hint)
    182189

    kasa/smart/modules/__init__.py

    Lines changed: 2 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -15,6 +15,7 @@
    1515
    from .frostprotection import FrostProtectionModule
    1616
    from .humidity import HumiditySensor
    1717
    from .ledmodule import LedModule
    18+
    from .lighteffectmodule import LightEffectModule
    1819
    from .lighttransitionmodule import LightTransitionModule
    1920
    from .reportmodule import ReportModule
    2021
    from .temperature import TemperatureSensor
    @@ -39,6 +40,7 @@
    3940
    "FanModule",
    4041
    "Firmware",
    4142
    "CloudModule",
    43+
    "LightEffectModule",
    4244
    "LightTransitionModule",
    4345
    "ColorTemperatureModule",
    4446
    "ColorModule",
    Lines changed: 112 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -0,0 +1,112 @@
    1+
    """Module for light effects."""
    2+
    3+
    from __future__ import annotations
    4+
    5+
    import base64
    6+
    import copy
    7+
    from typing import TYPE_CHECKING, Any
    8+
    9+
    from ...feature import Feature
    10+
    from ..smartmodule import SmartModule
    11+
    12+
    if TYPE_CHECKING:
    13+
    from ..smartdevice import SmartDevice
    14+
    15+
    16+
    class LightEffectModule(SmartModule):
    17+
    """Implementation of dynamic light effects."""
    18+
    19+
    REQUIRED_COMPONENT = "light_effect"
    20+
    QUERY_GETTER_NAME = "get_dynamic_light_effect_rules"
    21+
    AVAILABLE_BULB_EFFECTS = {
    22+
    "L1": "Party",
    23+
    "L2": "Relax",
    24+
    }
    25+
    LIGHT_EFFECTS_OFF = "Off"
    26+
    27+
    def __init__(self, device: SmartDevice, module: str):
    28+
    super().__init__(device, module)
    29+
    self._scenes_names_to_id: dict[str, str] = {}
    30+
    31+
    def _initialize_features(self):
    32+
    """Initialize features."""
    33+
    device = self._device
    34+
    self._add_feature(
    35+
    Feature(
    36+
    device,
    37+
    "Light effect",
    38+
    container=self,
    39+
    attribute_getter="effect",
    40+
    attribute_setter="set_effect",
    41+
    category=Feature.Category.Config,
    42+
    type=Feature.Type.Choice,
    43+
    choices_getter="effect_list",
    44+
    )
    45+
    )
    46+
    47+
    def _initialize_effects(self) -> dict[str, dict[str, Any]]:
    48+
    """Return built-in effects."""
    49+
    # Copy the effects so scene name updates do not update the underlying dict.
    50+
    effects = copy.deepcopy(
    51+
    {effect["id"]: effect for effect in self.data["rule_list"]}
    52+
    )
    53+
    for effect in effects.values():
    54+
    if not effect["scene_name"]:
    55+
    # If the name has not been edited scene_name will be an empty string
    56+
    effect["scene_name"] = self.AVAILABLE_BULB_EFFECTS[effect["id"]]
    57+
    else:
    58+
    # Otherwise it will be b64 encoded
    59+
    effect["scene_name"] = base64.b64decode(effect["scene_name"]).decode()
    60+
    self._scenes_names_to_id = {
    61+
    effect["scene_name"]: effect["id"] for effect in effects.values()
    62+
    }
    63+
    return effects
    64+
    65+
    @property
    66+
    def effect_list(self) -> list[str] | None:
    67+
    """Return built-in effects list.
    68+
    69+
    Example:
    70+
    ['Party', 'Relax', ...]
    71+
    """
    72+
    effects = [self.LIGHT_EFFECTS_OFF]
    73+
    effects.extend(
    74+
    [effect["scene_name"] for effect in self._initialize_effects().values()]
    75+
    )
    76+
    return effects
    77+
    78+
    @property
    79+
    def effect(self) -> str:
    80+
    """Return effect name."""
    81+
    # get_dynamic_light_effect_rules also has an enable property and current_rule_id
    82+
    # property that could be used here as an alternative
    83+
    if self._device._info["dynamic_light_effect_enable"]:
    84+
    return self._initialize_effects()[
    85+
    self._device._info["dynamic_light_effect_id"]
    86+
    ]["scene_name"]
    87+
    return self.LIGHT_EFFECTS_OFF
    88+
    89+
    async def set_effect(
    90+
    self,
    91+
    effect: str,
    92+
    ) -> None:
    93+
    """Set an effect for the device.
    94+
    95+
    The device doesn't store an active effect while not enabled so store locally.
    96+
    """
    97+
    if effect != self.LIGHT_EFFECTS_OFF and effect not in self._scenes_names_to_id:
    98+
    raise ValueError(
    99+
    f"Cannot set light effect to {effect}, possible values "
    100+
    f"are: {self.LIGHT_EFFECTS_OFF} "
    101+
    f"{' '.join(self._scenes_names_to_id.keys())}"
    102+
    )
    103+
    enable = effect != self.LIGHT_EFFECTS_OFF
    104+
    params: dict[str, bool | str] = {"enable": enable}
    105+
    if enable:
    106+
    effect_id = self._scenes_names_to_id[effect]
    107+
    params["id"] = effect_id
    108+
    return await self.call("set_dynamic_light_effect_rule_enable", params)
    109+
    110+
    def query(self) -> dict:
    111+
    """Query to execute during the update cycle."""
    112+
    return {self.QUERY_GETTER_NAME: {"start_index": 0}}

    kasa/smart/smartdevice.py

    Lines changed: 5 additions & 53 deletions
    Original file line numberDiff line numberDiff line change
    @@ -40,11 +40,6 @@
    4040
    # same issue, homekit perhaps?
    4141
    WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule 10000 , TimeModule, Firmware, CloudModule]
    4242

    43-
    AVAILABLE_BULB_EFFECTS = {
    44-
    "L1": "Party",
    45-
    "L2": "Relax",
    46-
    }
    47-
    4843

    4944
    # Device must go last as the other interfaces also inherit Device
    5045
    # and python needs a consistent method resolution order.
    @@ -683,44 +678,6 @@ def valid_temperature_range(self) -> ColorTempRange:
    683678
    ColorTemperatureModule, self.modules["ColorTemperatureModule"]
    684679
    ).valid_temperature_range
    685680

    686-
    @property
    687-
    def has_effects(self) -> bool:
    688-
    """Return True if the device supports effects."""
    689-
    return "dynamic_light_effect_enable" in self._info
    690-
    691-
    @property
    692-
    def effect(self) -> dict:
    693-
    """Return effect state.
    694-
    695-
    This follows the format used by SmartLightStrip.
    696-
    697-
    Example:
    698-
    {'brightness': 50,
    699-
    'custom': 0,
    700-
    'enable': 0,
    701-
    'id': '',
    702-
    'name': ''}
    703-
    """
    704-
    # If no effect is active, dynamic_light_effect_id does not appear in info
    705-
    current_effect = self._info.get("dynamic_light_effect_id", "")
    706-
    data = {
    707-
    "brightness": self.brightness,
    708-
    "enable": current_effect != "",
    709-
    "id": current_effect,
    710-
    "name": AVAILABLE_BULB_EFFECTS.get(current_effect, ""),
    711-
    }
    712-
    713-
    return data
    714-
    715-
    @property
    716-
    def effect_list(self) -> list[str] | None:
    717-
    """Return built-in effects list.
    718-
    719-
    Example:
    720-
    ['Party', 'Relax', ...]
    721-
    """
    722-
    return list(AVAILABLE_BULB_EFFECTS.keys()) if self.has_effects else None
    723-
    724681
    @property
    725682
    def hsv(self) -> HSV:
    726683
    """Return the current HSV state of the bulb.
    @@ -807,17 +764,12 @@ async def set_brightness(
    807764
    brightness
    808765
    )
    809766

    810-
    async def set_effect(
    811-
    self,
    812-
    effect: str,
    813-
    *,
    814-
    brightness: int | None = None,
    815-
    transition: int | None = None,
    816-
    ) -> None:
    817-
    """Set an effect on the device."""
    818-
    raise NotImplementedError()
    819-
    820767
    @property
    821768
    def presets(self) -> list[BulbPreset]:
    822769
    """Return a list of available bulb setting presets."""
    823770
    return []
    771+
    772+
    @property
    773+
    def has_effects(self) -> bool:
    774+
    """Return True if the device supports effects."""
    775+
    return "LightEffectModule" in self.modules

    kasa/tests/fakeprotocol_smart.py

    Lines changed: 16 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -176,6 +176,19 @@ def _handle_control_child(self, params: dict):
    176176
    "Method %s not implemented for children" % child_method
    177177
    )
    178178

    179+
    def _set_light_effect(self, info, params):
    180+
    """Set or remove values as per the device behaviour."""
    181+
    info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"]
    182+
    info["get_dynamic_light_effect_rules"]["enable"] = params["enable"]
    183+
    if params["enable"]:
    184+
    info["get_device_info"]["dynamic_light_effect_id"] = params["id"]
    185+
    info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["enable"]
    186+
    else:
    187+
    if "dynamic_light_effect_id" in info["get_device_info"]:
    188+
    del info["get_device_info"]["dynamic_light_effect_id"]
    189+
    if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
    190+
    del info["get_dynamic_light_effect_rules"]["current_rule_id"]
    191+
    179192
    def _send_request(self, request_dict: dict):
    180193
    method = request_dict["method"]
    181194
    params = request_dict["params"]
    @@ -223,6 +236,9 @@ def _send_request(self, request_dict: dict):
    223236
    return retval
    224237
    elif method == "set_qs_info":
    225238
    return {"error_code": 0}
    239+
    elif method == "set_dynamic_light_effect_rule_enable":
    240+
    self._set_light_effect(info, params)
    241+
    return {"error_code": 0}
    226242
    elif method[:4] == "set_":
    227243
    target_method = f"get_{method[4:]}"
    228244
    info[target_method].update(params)
    Lines changed: 42 additions & 0 deletions
    Original file line numberDiff line numberDiff line change
    @@ -0,0 +1,42 @@
    1+
    from __future__ import annotations
    2+
    3+
    from itertools import chain
    4+
    from typing import cast
    5+
    6+
    import pytest
    7+
    from pytest_mock import MockerFixture
    8+
    9+
    from kasa import Device, Feature
    10+
    from kasa.smart.modules import LightEffectModule
    11+
    from kasa.tests.device_fixtures import parametrize
    12+
    13+
    light_effect = parametrize(
    14+
    "has light effect", component_filter="light_effect", protocol_filter={"SMART"}
    15+
    )
    16+
    17+
    18+
    @light_effect
    19+
    async def test_light_effect(dev: Device, mocker: MockerFixture):
    20+
    """Test light effect."""
    21+
    light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule"))
    22+
    assert light_effect
    23+
    24+
    feature = light_effect._module_features["light_effect"]
    25+
    assert feature.type == Feature.Type.Choice
    26+
    27+
    call = mocker.spy(light_effect, "call")
    28+
    assert feature.choices == light_effect.effect_list
    29+
    assert feature.choices
    30+
    for effect in chain(reversed(feature.choices), feature.choices):
    31+
    await light_effect.set_effect(effect)
    32+
    enable = effect != LightEffectModule.LIGHT_EFFECTS_OFF
    33+
    params: dict[str, bool | str] = {"enable": enable}
    34+
    if enable:
    35+
    params["id"] = light_effect._scenes_names_to_id[effect]
    36+
    call.assert_called_with("set_dynamic_light_effect_rule_enable", params)
    37+
    await dev.update()
    38+
    assert light_effect.effect == effect
    39+
    assert feature.value == effect
    40+
    41+
    with pytest.raises(ValueError):
    42+
    await light_effect.set_effect("foobar")

    0 commit comments

    Comments
     (0)
    0