From fb417343428c4f458eaeff4bfe525f94bdce292d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Mar 2022 06:34:09 -1000 Subject: [PATCH] Add support for custom effects to tplink light strips (#68502) --- homeassistant/components/tplink/light.py | 173 +++++++++++++++- homeassistant/components/tplink/services.yaml | 183 +++++++++++++++++ tests/components/tplink/test_light.py | 192 ++++++++++++++++++ 3 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tplink/services.yaml diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index b2c6f207f3e..4bafb0472e9 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -1,10 +1,12 @@ """Support for TPLink lights.""" from __future__ import annotations +from collections.abc import Sequence import logging -from typing import Any, cast +from typing import Any, Final, cast from kasa import SmartBulb, SmartLightStrip +import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -22,6 +24,8 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired, @@ -35,6 +39,97 @@ from .entity import CoordinatedTPLinkEntity, async_refresh_after _LOGGER = logging.getLogger(__name__) +SERVICE_RANDOM_EFFECT = "random_effect" +SERVICE_SEQUENCE_EFFECT = "sequence_effect" + +HUE = vol.Range(min=0, max=360) +SAT = vol.Range(min=0, max=100) +VAL = vol.Range(min=0, max=100) +TRANSITION = vol.Range(min=0, max=6000) +HSV_SEQUENCE = vol.ExactSequence((HUE, SAT, VAL)) + +BASE_EFFECT_DICT: Final = { + vol.Optional("brightness", default=100): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional("duration", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=5000) + ), + vol.Optional("transition", default=0): vol.All(vol.Coerce(int), TRANSITION), + vol.Optional("segments", default=[0]): vol.All( + cv.ensure_list_csv, + vol.Length(min=1, max=80), + [vol.All(vol.Coerce(int), vol.Range(min=0, max=80))], + ), +} + +SEQUENCE_EFFECT_DICT: Final = { + **BASE_EFFECT_DICT, + vol.Required("sequence"): vol.All( + cv.ensure_list, + vol.Length(min=1, max=16), + [vol.All(vol.Coerce(tuple), HSV_SEQUENCE)], + ), + vol.Optional("spread", default=1): vol.All( + vol.Coerce(int), vol.Range(min=1, max=16) + ), + vol.Optional("direction", default=4): vol.All( + vol.Coerce(int), vol.Range(min=1, max=4) + ), +} + +RANDOM_EFFECT_DICT: Final = { + **BASE_EFFECT_DICT, + vol.Optional("fadeoff", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=3000) + ), + vol.Optional("hue_range"): vol.All( + cv.ensure_list_csv, [vol.Coerce(int)], vol.ExactSequence((HUE, HUE)) + ), + vol.Optional("saturation_range"): vol.All( + cv.ensure_list_csv, [vol.Coerce(int)], vol.ExactSequence((SAT, SAT)) + ), + vol.Optional("brightness_range"): vol.All( + cv.ensure_list_csv, [vol.Coerce(int)], vol.ExactSequence((VAL, VAL)) + ), + vol.Optional("transition_range"): vol.All( + cv.ensure_list_csv, + [vol.Coerce(int)], + vol.ExactSequence((TRANSITION, TRANSITION)), + ), + vol.Required("init_states"): vol.All( + cv.ensure_list_csv, [vol.Coerce(int)], HSV_SEQUENCE + ), + vol.Optional("random_seed", default=100): vol.All( + vol.Coerce(int), vol.Range(min=1, max=100) + ), + vol.Required("backgrounds"): vol.All( + cv.ensure_list, + vol.Length(min=1, max=16), + [vol.All(vol.Coerce(tuple), HSV_SEQUENCE)], + ), +} + + +@callback +def _async_build_base_effect( + brightness: int, + duration: int, + transition: int, + segments: list[int], +) -> dict[str, Any]: + return { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": brightness, + "name": "Custom", + "segments": segments, + "expansion_strategy": 1, + "enable": 1, + "duration": duration, + "transition": transition, + } + async def async_setup_entry( hass: HomeAssistant, @@ -51,6 +146,17 @@ async def async_setup_entry( ) ] ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_RANDOM_EFFECT, + RANDOM_EFFECT_DICT, + "async_set_random_effect", + ) + platform.async_register_entity_service( + SERVICE_SEQUENCE_EFFECT, + SEQUENCE_EFFECT_DICT, + "async_set_sequence_effect", + ) elif coordinator.device.is_bulb or coordinator.device.is_dimmer: async_add_entities( [TPLinkSmartBulb(cast(SmartBulb, coordinator.device), coordinator)] @@ -219,6 +325,7 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): ) -> None: """Initialize the smart light strip.""" super().__init__(device, coordinator) + self._last_custom_effect: dict[str, Any] = {} @property def supported_features(self) -> int: @@ -259,9 +366,73 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): ): if not self.device.effect["custom"]: await self.device.set_effect(self.device.effect["name"]) + elif self._last_custom_effect: + await self.device.set_custom_effect(self._last_custom_effect) # The device does not remember custom effects # so we must set a default value or it can never turn back on else: await self.device.set_hsv(0, 0, 100, transition=transition) else: await self._async_turn_on_with_brightness(brightness, transition) + + async def async_set_random_effect( + self, + brightness: int, + duration: int, + transition: int, + segments: list[int], + fadeoff: int, + init_states: tuple[int, int, int], + random_seed: int, + backgrounds: Sequence[tuple[int, int, int]], + hue_range: tuple[int, int] | None = None, + saturation_range: tuple[int, int] | None = None, + brightness_range: tuple[int, int] | None = None, + transition_range: tuple[int, int] | None = None, + ) -> None: + """Set a random effect.""" + effect: dict[str, Any] = { + **_async_build_base_effect(brightness, duration, transition, segments), + "type": "random", + "init_states": [init_states], + "random_seed": random_seed, + "backgrounds": backgrounds, + } + if fadeoff: + effect["fadeoff"] = fadeoff + if hue_range: + effect["hue_range"] = hue_range + if saturation_range: + effect["saturation_range"] = saturation_range + if brightness_range: + effect["brightness_range"] = brightness_range + effect["brightness"] = min( + brightness_range[1], max(brightness, brightness_range[0]) + ) + if transition_range: + effect["transition_range"] = transition_range + effect["transition"] = 0 + self._last_custom_effect = effect + await self.device.set_custom_effect(effect) + + async def async_set_sequence_effect( + self, + brightness: int, + duration: int, + transition: int, + segments: list[int], + sequence: Sequence[tuple[int, int, int]], + spread: int, + direction: int, + ) -> None: + """Set a sequence effect.""" + effect: dict[str, Any] = { + **_async_build_base_effect(brightness, duration, transition, segments), + "type": "sequence", + "sequence": sequence, + "repeat_times": 0, + "spread": spread, + "direction": direction, + } + self._last_custom_effect = effect + await self.device.set_custom_effect(effect) diff --git a/homeassistant/components/tplink/services.yaml b/homeassistant/components/tplink/services.yaml new file mode 100644 index 00000000000..26e002e0e30 --- /dev/null +++ b/homeassistant/components/tplink/services.yaml @@ -0,0 +1,183 @@ +sequence_effect: + description: Set a sequence effect + target: + entity: + integration: tplink + domain: light + fields: + sequence: + description: List of HSV sequences (Max 16) + example: | + - [340, 20, 50] + - [20, 50, 50] + - [0, 100, 50] + required: true + selector: + object: + segments: + description: List of Segments (0 for all) + example: 0, 2, 4, 6, 8 + default: 0 + required: false + selector: + object: + brightness: + description: Initial brightness + example: 80 + default: 100 + required: false + selector: + number: + min: 1 + step: 1 + max: 100 + unit_of_measurement: "%" + duration: + description: Duration + example: 0 + default: 0 + required: false + selector: + number: + min: 0 + step: 1 + max: 5000 + unit_of_measurement: "ms" + transition: + description: Transition + example: 2000 + default: 0 + required: false + selector: + number: + min: 0 + step: 1 + max: 6000 + unit_of_measurement: "ms" + spread: + description: Speed of spread + example: 1 + default: 0 + required: false + selector: + number: + min: 0 + step: 1 + max: 16 + direction: + description: Direction + example: 1 + default: 4 + required: false + selector: + number: + min: 1 + step: 1 + max: 4 +random_effect: + description: Set a random effect + target: + entity: + integration: tplink + domain: light + fields: + init_states: + description: Initial HSV sequence + example: [199, 99, 96] + required: true + selector: + object: + backgrounds: + description: List of HSV sequences (Max 16) + example: | + - [199, 89, 50] + - [160, 50, 50] + - [180, 100, 50] + required: true + selector: + object: + segments: + description: List of segments (0 for all) + example: 0, 2, 4, 6, 8 + default: 0 + required: false + selector: + object: + brightness: + description: Initial brightness + example: 90 + default: 100 + required: false + selector: + number: + min: 1 + step: 1 + max: 100 + unit_of_measurement: "%" + duration: + description: Duration + example: 0 + default: 0 + required: false + selector: + number: + min: 0 + step: 1 + max: 5000 + unit_of_measurement: "ms" + transition: + description: Transition + example: 2000 + default: 0 + required: false + selector: + number: + min: 0 + step: 1 + max: 6000 + unit_of_measurement: "ms" + fadeoff: + description: Fade off + example: 2000 + default: 0 + required: false + selector: + number: + min: 0 + step: 1 + max: 3000 + unit_of_measurement: "ms" + hue_range: + description: Range of hue + example: 340, 360 + required: false + selector: + object: + saturation_range: + description: Range of saturation + example: 40, 95 + required: false + selector: + object: + brightness_range: + description: Range of brightness + example: 90, 100 + required: false + selector: + object: + transition_range: + description: Range of transition + example: 2000, 6000 + required: false + selector: + object: + random_seed: + description: Random seed + example: 80 + default: 100 + required: false + selector: + number: + min: 1 + step: 1 + max: 100 diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 3745b5b09a0..db2fdbebb41 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -458,6 +458,151 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: assert state.attributes[ATTR_EFFECT_LIST] is None +async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: + """Test smart strip custom random effects.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + strip = _mocked_smart_light_strip() + + with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + DOMAIN, + "random_effect", + { + ATTR_ENTITY_ID: entity_id, + "init_states": [340, 20, 50], + "backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], + }, + blocking=True, + ) + strip.set_custom_effect.assert_called_once_with( + { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 0, + "transition": 0, + "type": "random", + "init_states": [[340, 20, 50]], + "random_seed": 100, + "backgrounds": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], + } + ) + strip.set_custom_effect.reset_mock() + + strip.effect = { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "enable": 1, + } + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + strip.is_off = True + strip.is_on = False + strip.effect = { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "enable": 0, + } + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert ATTR_EFFECT not in state.attributes + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + strip.set_custom_effect.assert_called_once_with( + { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 0, + "transition": 0, + "type": "random", + "init_states": [[340, 20, 50]], + "random_seed": 100, + "backgrounds": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], + } + ) + strip.set_custom_effect.reset_mock() + + await hass.services.async_call( + DOMAIN, + "random_effect", + { + ATTR_ENTITY_ID: entity_id, + "init_states": [340, 20, 50], + "backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], + "random_seed": 50, + "brightness": 80, + "duration": 5000, + "transition": 2000, + "fadeoff": 3000, + "hue_range": [0, 360], + "saturation_range": [0, 100], + "brightness_range": [0, 100], + "transition_range": [2000, 3000], + }, + ) + await hass.async_block_till_done() + + strip.set_custom_effect.assert_called_once_with( + { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 80, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 5000, + "transition": 0, + "type": "random", + "init_states": [[340, 20, 50]], + "random_seed": 50, + "backgrounds": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], + "fadeoff": 3000, + "hue_range": [0, 360], + "saturation_range": [0, 100], + "brightness_range": [0, 100], + "transition_range": [2000, 3000], + } + ) + strip.set_custom_effect.reset_mock() + + async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> None: """Test smart strip custom random effects at startup.""" already_migrated_config_entry = MockConfigEntry( @@ -489,3 +634,50 @@ async def test_smart_strip_custom_random_effect_at_start(hass: HomeAssistant) -> ) strip.set_hsv.assert_called_with(0, 0, 100, transition=None) strip.set_hsv.reset_mock() + + +async def test_smart_strip_custom_sequence_effect(hass: HomeAssistant) -> None: + """Test smart strip custom sequence effects.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + strip = _mocked_smart_light_strip() + + with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + DOMAIN, + "sequence_effect", + { + ATTR_ENTITY_ID: entity_id, + "sequence": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], + }, + blocking=True, + ) + strip.set_custom_effect.assert_called_once_with( + { + "custom": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "brightness": 100, + "name": "Custom", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "duration": 0, + "transition": 0, + "type": "sequence", + "sequence": [(340, 20, 50), (20, 50, 50), (0, 100, 50)], + "repeat_times": 0, + "spread": 1, + "direction": 4, + } + ) + strip.set_custom_effect.reset_mock()