From 58f6517445541df0db299a2b9c19847814d33949 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 21 Mar 2022 11:10:12 -1000 Subject: [PATCH] Add effect support for light strips (#293) * Add effect support for KL430 * KL400 supports effects * Add KL400 fixture * Comments from review * actually commit the remove --- kasa/cli.py | 21 ++ kasa/effects.py | 296 ++++++++++++++++++ kasa/smartbulb.py | 6 + kasa/smartlightstrip.py | 47 ++- .../tests/fixtures/KL400L5(US)_1.0_1.0.8.json | 57 ++++ kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json | 57 ++++ kasa/tests/newfakes.py | 7 + kasa/tests/test_lightstrip.py | 19 ++ 8 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 kasa/effects.py create mode 100644 kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json create mode 100644 kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json diff --git a/kasa/cli.py b/kasa/cli.py index e9724ec5..696dd9aa 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -371,6 +371,27 @@ async def temperature(dev: SmartBulb, temperature: int, transition: int): return await dev.set_color_temp(temperature, transition=transition) +@cli.command() +@click.argument("effect", type=click.STRING, default=None, required=False) +@click.pass_context +@pass_dev +async def effect(dev, ctx, effect): + """Set an effect.""" + if not dev.has_effects: + click.echo("Device does not support effects") + return + if effect is None: + raise click.BadArgumentUsage( + f"Setting an effect requires a named built-in effect: {dev.effect_list}", + ctx, + ) + if effect not in dev.effect_list: + raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx) + + click.echo(f"Setting Effect: {effect}") + return await dev.set_effect(effect) + + @cli.command() @click.argument("h", type=click.IntRange(0, 360), default=None, required=False) @click.argument("s", type=click.IntRange(0, 100), default=None, required=False) diff --git a/kasa/effects.py b/kasa/effects.py new file mode 100644 index 00000000..cf72bb8d --- /dev/null +++ b/kasa/effects.py @@ -0,0 +1,296 @@ +"""Module for light strip effects (LB*, KL*, KB*).""" + +from typing import List, cast + +EFFECT_AURORA = { + "custom": 0, + "id": "xqUxDhbAhNLqulcuRMyPBmVGyTOyEMEu", + "brightness": 100, + "name": "Aurora", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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": "tIwTRQBqJpeNKbrtBMFCgkdPTbAQGfRP", + "brightness": 100, + "name": "Bubbling Cauldron", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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": "HCOttllMkNffeHjEOLEgrFJjbzQHoxEJ", + "brightness": 100, + "name": "Candy Cane", + "segments": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "expansion_strategy": 1, + "enable": 1, + "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": "bwTatyinOUajKrDwzMmqxxJdnInQUgvM", + "brightness": 100, + "name": "Christmas", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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": "bCTItKETDFfrKANolgldxfgOakaarARs", + "brightness": 100, + "name": "Flicker", + "segments": [1], + "expansion_strategy": 1, + "enable": 1, + "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_HANUKKAH = { + "custom": 0, + "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", + "brightness": 100, + "name": "Hanukkah", + "segments": [1], + "expansion_strategy": 1, + "enable": 1, + "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": "oJnFHsVQzFUTeIOBAhMRfVeujmSauhjJ", + "brightness": 80, + "name": "Haunted Mansion", + "segments": [80], + "expansion_strategy": 2, + "enable": 1, + "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": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb", + "brightness": 70, + "name": "Icicle", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE", + "brightness": 100, + "name": "Lightning", + "segments": [7, 20, 23, 32, 34, 35, 49, 65, 66, 74, 80], + "expansion_strategy": 1, + "enable": 1, + "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": 600, + "backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]], +} +EFFECT_OCEAN = { + "custom": 0, + "id": "oJjUMosgEMrdumfPANKbkFmBcAdEQsPy", + "brightness": 30, + "name": "Ocean", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT", + "brightness": 100, + "name": "Rainbow", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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": "QbDFwiSFmLzQenUOPnJrsGqyIVrJrRsl", + "brightness": 30, + "name": "Raindrop", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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": "URdUpEdQbnOOechDBPMkKrwhSupLyvAg", + "brightness": 100, + "name": "Spring", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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_VALENTINES = { + "custom": 0, + "id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz", + "brightness": 100, + "name": "Valentines", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "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_V1 = [ + EFFECT_AURORA, + EFFECT_BUBBLING_CAULDRON, + EFFECT_CANDY_CANE, + EFFECT_CHRISTMAS, + EFFECT_FLICKER, + EFFECT_HANUKKAH, + EFFECT_HAUNTED_MANSION, + EFFECT_ICICLE, + EFFECT_LIGHTNING, + EFFECT_OCEAN, + EFFECT_RAINBOW, + EFFECT_RAINDROP, + EFFECT_SPRING, + EFFECT_VALENTINES, +] + +EFFECT_NAMES_V1: List[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1] +EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1} diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 8ccce15a..e5dcfbe9 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -172,6 +172,12 @@ class SmartBulb(SmartDevice): return light_state + @property # type: ignore + @requires_update + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return "lighting_effect_state" in self.sys_info + async def get_light_details(self) -> Dict[str, int]: """Return light details. diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index c579fec2..08723453 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -1,8 +1,9 @@ """Module for light strips (KL430).""" -from typing import Any, Dict +from typing import Any, Dict, List, Optional +from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from .smartbulb import SmartBulb -from .smartdevice import DeviceType, requires_update +from .smartdevice import DeviceType, SmartDeviceException, requires_update class SmartLightStrip(SmartBulb): @@ -64,6 +65,16 @@ class SmartLightStrip(SmartBulb): """ return self.sys_info["lighting_effect_state"] + @property # type: ignore + @requires_update + def effect_list(self) -> Optional[List[str]]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + return EFFECT_NAMES_V1 if self.has_effects else None + @property # type: ignore @requires_update def state_information(self) -> Dict[str, Any]: @@ -71,5 +82,37 @@ class SmartLightStrip(SmartBulb): info = super().state_information info["Length"] = self.length + if self.has_effects: + info["Effect"] = self.effect["name"] return info + + @requires_update + async def set_effect( + self, + effect: str, + ) -> None: + """Set an effect on the device. + + :param str effect: The effect to set + """ + if effect not in EFFECT_MAPPING_V1: + raise SmartDeviceException(f"The effect {effect} is not a built in effect.") + await self.set_custom_effect(EFFECT_MAPPING_V1[effect]) + + @requires_update + 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 + """ + if not self.has_effects: + raise SmartDeviceException("Bulb does not support effects.") + await self._query_helper( + "smartlife.iot.lighting_effect", + "set_lighting_effect", + effect_dict, + ) diff --git a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json b/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json new file mode 100644 index 00000000..a737cd2a --- /dev/null +++ b/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 10800, + "total_wh": 1 + } + }, + "system": { + "get_sysinfo": { + "LEF": 0, + "active_mode": "none", + "alias": "Kl400", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "length": 1, + "light_state": { + "brightness": 100, + "color_temp": 6500, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 0, + "enable": 1, + "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", + "name": "Hanukkah" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL400L5(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -44, + "status": "new", + "sw_ver": "1.0.8 Build 211018 Rel.162056" + } + } +} diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json b/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json new file mode 100644 index 00000000..d5f2eafb --- /dev/null +++ b/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 11150, + "total_wh": 18 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "active_mode": "none", + "alias": "kl430 updated", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "brightness": 100, + "color_temp": 0, + "hue": 194, + "mode": "normal", + "on_off": 1, + "saturation": 50 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 0, + "enable": 1, + "id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT", + "name": "Rainbow" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -58, + "status": "new", + "sw_ver": "1.0.9 Build 210915 Rel.170534" + } + } +} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 2e859e36..904d45c7 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -359,6 +359,10 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): self.proto["system"]["get_sysinfo"]["relay_state"] = 1 self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] + def set_lighting_effect(self, effect, *args): + _LOGGER.debug("Setting light effect to %s", effect) + self.proto["system"]["get_sysinfo"]["lighting_effect_state"] = dict(effect) + def transition_light_state(self, state_changes, *args): _LOGGER.debug("Setting light state to %s", state_changes) light_state = self.proto["system"]["get_sysinfo"]["light_state"] @@ -422,6 +426,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): "get_light_state": light_state, "transition_light_state": transition_light_state, }, + "smartlife.iot.lighting_effect": { + "set_lighting_effect": set_lighting_effect, + }, # lightstrip follows the same payloads but uses different module & method "smartlife.iot.lightStrip": { "set_light_state": transition_light_state, diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index 7a8d8726..e53bb1f7 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,4 +1,7 @@ +import pytest + from kasa import DeviceType, SmartLightStrip +from kasa.exceptions import SmartDeviceException from .conftest import lightstrip, pytestmark @@ -15,3 +18,19 @@ async def test_lightstrip_effect(dev: SmartLightStrip): assert isinstance(dev.effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: assert k in dev.effect + + +@lightstrip +async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): + with pytest.raises(SmartDeviceException): + await dev.set_effect("Not real") + + await dev.set_effect("Candy Cane") + assert dev.effect["name"] == "Candy Cane" + assert dev.state_information["Effect"] == "Candy Cane" + + +@lightstrip +async def test_effects_lightstrip_has_effects(dev: SmartLightStrip): + assert dev.has_effects is True + assert dev.effect_list