mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 17:27:10 +00:00
Add support for custom effects to tplink light strips (#68502)
This commit is contained in:
parent
d75f577b88
commit
fb41734342
@ -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)
|
||||
|
183
homeassistant/components/tplink/services.yaml
Normal file
183
homeassistant/components/tplink/services.yaml
Normal 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
|
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user