Add support for custom effects to tplink light strips (#68502)

This commit is contained in:
J. Nick Koston 2022-03-30 06:34:09 -10:00 committed by GitHub
parent d75f577b88
commit fb41734342
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 547 additions and 1 deletions

View File

@ -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)

View File

@ -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

View File

@ -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()