mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-01-22 20:57:07 +00:00
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:
parent
5ef81f4669
commit
5b486074e2
38
kasa/cli.py
38
kasa/cli.py
@ -586,6 +586,7 @@ def _echo_features(
|
|||||||
title: str,
|
title: str,
|
||||||
category: Feature.Category | None = None,
|
category: Feature.Category | None = None,
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
|
indent: str = "\t",
|
||||||
):
|
):
|
||||||
"""Print out a listing of features and their values."""
|
"""Print out a listing of features and their values."""
|
||||||
if category is not None:
|
if category is not None:
|
||||||
@ -598,13 +599,13 @@ def _echo_features(
|
|||||||
echo(f"[bold]{title}[/bold]")
|
echo(f"[bold]{title}[/bold]")
|
||||||
for _, feat in features.items():
|
for _, feat in features.items():
|
||||||
try:
|
try:
|
||||||
echo(f"\t{feat}")
|
echo(f"{indent}{feat}")
|
||||||
if verbose:
|
if verbose:
|
||||||
echo(f"\t\tType: {feat.type}")
|
echo(f"{indent}\tType: {feat.type}")
|
||||||
echo(f"\t\tCategory: {feat.category}")
|
echo(f"{indent}\tCategory: {feat.category}")
|
||||||
echo(f"\t\tIcon: {feat.icon}")
|
echo(f"{indent}\tIcon: {feat.icon}")
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
echo(f"\t{feat.name} ({feat.id}): got exception (%s)" % ex)
|
echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]")
|
||||||
|
|
||||||
|
|
||||||
def _echo_all_features(features, *, verbose=False, title_prefix=None):
|
def _echo_all_features(features, *, verbose=False, title_prefix=None):
|
||||||
@ -1219,22 +1220,15 @@ async def feature(dev: Device, child: str, name: str, value):
|
|||||||
echo(f"Targeting child device {child}")
|
echo(f"Targeting child device {child}")
|
||||||
dev = dev.get_child_device(child)
|
dev = dev.get_child_device(child)
|
||||||
if not name:
|
if not name:
|
||||||
|
_echo_features(dev.features, "\n[bold]== Features ==[/bold]\n", indent="")
|
||||||
def _print_features(dev):
|
|
||||||
for name, feat in dev.features.items():
|
|
||||||
try:
|
|
||||||
unit = f" {feat.unit}" if feat.unit else ""
|
|
||||||
echo(f"\t{feat.name} ({name}): {feat.value}{unit}")
|
|
||||||
except Exception as ex:
|
|
||||||
echo(f"\t{feat.name} ({name}): [red]{ex}[/red]")
|
|
||||||
|
|
||||||
echo("[bold]== Features ==[/bold]")
|
|
||||||
_print_features(dev)
|
|
||||||
|
|
||||||
if dev.children:
|
if dev.children:
|
||||||
for child_dev in dev.children:
|
for child_dev in dev.children:
|
||||||
echo(f"[bold]== Child {child_dev.alias} ==")
|
_echo_features(
|
||||||
_print_features(child_dev)
|
child_dev.features,
|
||||||
|
f"\n[bold]== Child {child_dev.alias} ==\n",
|
||||||
|
indent="",
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -1249,9 +1243,13 @@ async def feature(dev: Device, child: str, name: str, value):
|
|||||||
echo(f"{feat.name} ({name}): {feat.value}{unit}")
|
echo(f"{feat.name} ({name}): {feat.value}{unit}")
|
||||||
return feat.value
|
return feat.value
|
||||||
|
|
||||||
echo(f"Setting {name} to {value}")
|
|
||||||
value = ast.literal_eval(value)
|
value = ast.literal_eval(value)
|
||||||
return await dev.features[name].set_value(value)
|
echo(f"Changing {name} from {feat.value} to {value}")
|
||||||
|
response = await dev.features[name].set_value(value)
|
||||||
|
await dev.update()
|
||||||
|
echo(f"New state: {feat.value}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
@ -174,9 +174,16 @@ class Feature:
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
try:
|
try:
|
||||||
value = self.value
|
value = self.value
|
||||||
|
choices = self.choices
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
return f"Unable to read value ({self.id}): {ex}"
|
return f"Unable to read value ({self.id}): {ex}"
|
||||||
|
|
||||||
|
if self.type == Feature.Type.Choice:
|
||||||
|
if not isinstance(choices, list) or value not in choices:
|
||||||
|
return f"Value {value} is not a valid choice ({self.id}): {choices}"
|
||||||
|
value = " ".join(
|
||||||
|
[f"*{choice}*" if choice == value else choice for choice in choices]
|
||||||
|
)
|
||||||
if self.precision_hint is not None and value is not None:
|
if self.precision_hint is not None and value is not None:
|
||||||
value = round(self.value, self.precision_hint)
|
value = round(self.value, self.precision_hint)
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ from .firmware import Firmware
|
|||||||
from .frostprotection import FrostProtectionModule
|
from .frostprotection import FrostProtectionModule
|
||||||
from .humidity import HumiditySensor
|
from .humidity import HumiditySensor
|
||||||
from .ledmodule import LedModule
|
from .ledmodule import LedModule
|
||||||
|
from .lighteffectmodule import LightEffectModule
|
||||||
from .lighttransitionmodule import LightTransitionModule
|
from .lighttransitionmodule import LightTransitionModule
|
||||||
from .reportmodule import ReportModule
|
from .reportmodule import ReportModule
|
||||||
from .temperature import TemperatureSensor
|
from .temperature import TemperatureSensor
|
||||||
@ -39,6 +40,7 @@ __all__ = [
|
|||||||
"FanModule",
|
"FanModule",
|
||||||
"Firmware",
|
"Firmware",
|
||||||
"CloudModule",
|
"CloudModule",
|
||||||
|
"LightEffectModule",
|
||||||
"LightTransitionModule",
|
"LightTransitionModule",
|
||||||
"ColorTemperatureModule",
|
"ColorTemperatureModule",
|
||||||
"ColorModule",
|
"ColorModule",
|
||||||
|
112
kasa/smart/modules/lighteffectmodule.py
Normal file
112
kasa/smart/modules/lighteffectmodule.py
Normal 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}}
|
@ -40,11 +40,6 @@ if TYPE_CHECKING:
|
|||||||
# same issue, homekit perhaps?
|
# same issue, homekit perhaps?
|
||||||
WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule]
|
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
|
# Device must go last as the other interfaces also inherit Device
|
||||||
# and python needs a consistent method resolution order.
|
# and python needs a consistent method resolution order.
|
||||||
@ -683,44 +678,6 @@ class SmartDevice(Bulb, Fan, Device):
|
|||||||
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
|
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
|
||||||
).valid_temperature_range
|
).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
|
@property
|
||||||
def hsv(self) -> HSV:
|
def hsv(self) -> HSV:
|
||||||
"""Return the current HSV state of the bulb.
|
"""Return the current HSV state of the bulb.
|
||||||
@ -807,17 +764,12 @@ class SmartDevice(Bulb, Fan, Device):
|
|||||||
brightness
|
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
|
@property
|
||||||
def presets(self) -> list[BulbPreset]:
|
def presets(self) -> list[BulbPreset]:
|
||||||
"""Return a list of available bulb setting presets."""
|
"""Return a list of available bulb setting presets."""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_effects(self) -> bool:
|
||||||
|
"""Return True if the device supports effects."""
|
||||||
|
return "LightEffectModule" in self.modules
|
||||||
|
@ -176,6 +176,19 @@ 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):
|
||||||
|
"""Set or remove values as per the device behaviour."""
|
||||||
|
info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"]
|
||||||
|
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"]
|
||||||
|
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 _send_request(self, request_dict: dict):
|
def _send_request(self, request_dict: dict):
|
||||||
method = request_dict["method"]
|
method = request_dict["method"]
|
||||||
params = request_dict["params"]
|
params = request_dict["params"]
|
||||||
@ -223,6 +236,9 @@ class FakeSmartTransport(BaseTransport):
|
|||||||
return retval
|
return retval
|
||||||
elif method == "set_qs_info":
|
elif method == "set_qs_info":
|
||||||
return {"error_code": 0}
|
return {"error_code": 0}
|
||||||
|
elif method == "set_dynamic_light_effect_rule_enable":
|
||||||
|
self._set_light_effect(info, params)
|
||||||
|
return {"error_code": 0}
|
||||||
elif method[:4] == "set_":
|
elif method[:4] == "set_":
|
||||||
target_method = f"get_{method[4:]}"
|
target_method = f"get_{method[4:]}"
|
||||||
info[target_method].update(params)
|
info[target_method].update(params)
|
||||||
|
42
kasa/tests/smart/modules/test_light_effect.py
Normal file
42
kasa/tests/smart/modules/test_light_effect.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itertools import chain
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
from kasa import Device, Feature
|
||||||
|
from kasa.smart.modules import LightEffectModule
|
||||||
|
from kasa.tests.device_fixtures import parametrize
|
||||||
|
|
||||||
|
light_effect = parametrize(
|
||||||
|
"has light effect", component_filter="light_effect", protocol_filter={"SMART"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@light_effect
|
||||||
|
async def test_light_effect(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test light effect."""
|
||||||
|
light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule"))
|
||||||
|
assert light_effect
|
||||||
|
|
||||||
|
feature = light_effect._module_features["light_effect"]
|
||||||
|
assert feature.type == Feature.Type.Choice
|
||||||
|
|
||||||
|
call = mocker.spy(light_effect, "call")
|
||||||
|
assert feature.choices == light_effect.effect_list
|
||||||
|
assert feature.choices
|
||||||
|
for effect in chain(reversed(feature.choices), feature.choices):
|
||||||
|
await light_effect.set_effect(effect)
|
||||||
|
enable = effect != LightEffectModule.LIGHT_EFFECTS_OFF
|
||||||
|
params: dict[str, bool | str] = {"enable": enable}
|
||||||
|
if enable:
|
||||||
|
params["id"] = light_effect._scenes_names_to_id[effect]
|
||||||
|
call.assert_called_with("set_dynamic_light_effect_rule_enable", params)
|
||||||
|
await dev.update()
|
||||||
|
assert light_effect.effect == effect
|
||||||
|
assert feature.value == effect
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await light_effect.set_effect("foobar")
|
@ -689,6 +689,17 @@ async def test_feature(mocker, runner):
|
|||||||
assert res.exit_code == 0
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_features_all(discovery_mock, mocker, runner):
|
||||||
|
"""Test feature command on all fixtures."""
|
||||||
|
res = await runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--host", "127.0.0.123", "--debug", "feature"],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
assert "== Features ==" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_feature_single(mocker, runner):
|
async def test_feature_single(mocker, runner):
|
||||||
"""Test feature command returning single value."""
|
"""Test feature command returning single value."""
|
||||||
dummy_device = await get_device_for_fixture_protocol(
|
dummy_device = await get_device_for_fixture_protocol(
|
||||||
@ -736,7 +747,7 @@ async def test_feature_set(mocker, runner):
|
|||||||
)
|
)
|
||||||
|
|
||||||
led_setter.assert_called_with(True)
|
led_setter.assert_called_with(True)
|
||||||
assert "Setting led to True" in res.output
|
assert "Changing led from False to True" in res.output
|
||||||
assert res.exit_code == 0
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
@ -762,14 +773,14 @@ async def test_feature_set_child(mocker, runner):
|
|||||||
"--child",
|
"--child",
|
||||||
child_id,
|
child_id,
|
||||||
"state",
|
"state",
|
||||||
"False",
|
"True",
|
||||||
],
|
],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
get_child_device.assert_called()
|
get_child_device.assert_called()
|
||||||
setter.assert_called_with(False)
|
setter.assert_called_with(True)
|
||||||
|
|
||||||
assert f"Targeting child device {child_id}"
|
assert f"Targeting child device {child_id}"
|
||||||
assert "Setting state to False" in res.output
|
assert "Changing state from False to True" in res.output
|
||||||
assert res.exit_code == 0
|
assert res.exit_code == 0
|
||||||
|
Loading…
Reference in New Issue
Block a user