[light] Add color option to strobe and other color state (#19874)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Clyde Stubbs
2026-10-01 19:02:58 +00:00
committed by GitHub
co-authored by Claude Sonnet 5 J. Nick Koston
parent 0ed997da92
commit bfb7e76b2e
6 changed files with 222 additions and 47 deletions
+85 -47
View File
@@ -31,9 +31,10 @@ from esphome.const import (
)
from esphome.cpp_generator import MockObjClass
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from esphome.util import Registry
from .automation import validate_light_state
from .automation import COLOR_SCHEMA, color_to_rgb, validate_light_state
from .types import (
COLOR_MODES,
AddressableColorWipeEffect,
@@ -88,6 +89,28 @@ MONOCHROMATIC_EFFECTS = []
RGB_EFFECTS = []
ADDRESSABLE_EFFECTS = []
def _with_color_option(schema: cv.Schema, *default_keys: str) -> cv.Schema:
"""Add the `color:` option to a per-item effect color schema.
`color_to_rgb` must run before the keys are defaulted to full level below, so a
`color:` can populate `default_keys` (red/green/blue and, where the effect has
one, color_brightness) and a plain channel list still defaults them to full
level. Centralizing both steps here means a new effect schema only has to call
this once instead of repeating the ordering rule itself.
"""
schema = schema.extend(COLOR_SCHEMA)
schema.add_extra(color_to_rgb)
def _default_to_full_level(config: ConfigType) -> ConfigType:
for key in default_keys:
config.setdefault(key, 1.0)
return config
schema.add_extra(_default_to_full_level)
return schema
EFFECTS_REGISTRY = Registry()
@@ -267,6 +290,30 @@ async def random_effect_to_code(config, effect_id):
return effect
STROBE_COLOR_SCHEMA = cv.Schema(
{
cv.Optional(CONF_STATE, default=True): validate_light_state,
cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_MODE): cv.enum(COLOR_MODES, upper=True, space="_"),
cv.Optional(CONF_COLOR_BRIGHTNESS): cv.percentage,
cv.Optional(CONF_RED): cv.percentage,
cv.Optional(CONF_GREEN): cv.percentage,
cv.Optional(CONF_BLUE): cv.percentage,
cv.Optional(CONF_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_TEMPERATURE): cv.color_temperature,
cv.Optional(CONF_COLD_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_WARM_WHITE, default=1.0): cv.percentage,
cv.Required(CONF_DURATION): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TRANSITION_LENGTH, default="0s"
): cv.positive_time_period_milliseconds,
}
)
STROBE_COLOR_SCHEMA = _with_color_option(
STROBE_COLOR_SCHEMA, CONF_COLOR_BRIGHTNESS, CONF_RED, CONF_GREEN, CONF_BLUE
)
@register_binary_effect(
"strobe",
StrobeLightEffect,
@@ -280,29 +327,7 @@ async def random_effect_to_code(config, effect_id):
],
): cv.All(
cv.ensure_list(
cv.Schema(
{
cv.Optional(CONF_STATE, default=True): validate_light_state,
cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_MODE): cv.enum(
COLOR_MODES, upper=True, space="_"
),
cv.Optional(CONF_COLOR_BRIGHTNESS, default=1.0): cv.percentage,
cv.Optional(CONF_RED, default=1.0): cv.percentage,
cv.Optional(CONF_GREEN, default=1.0): cv.percentage,
cv.Optional(CONF_BLUE, default=1.0): cv.percentage,
cv.Optional(CONF_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_TEMPERATURE): cv.color_temperature,
cv.Optional(CONF_COLD_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_WARM_WHITE, default=1.0): cv.percentage,
cv.Required(
CONF_DURATION
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TRANSITION_LENGTH, default="0s"
): cv.positive_time_period_milliseconds,
}
),
STROBE_COLOR_SCHEMA,
cv.has_at_least_one_key(
CONF_STATE,
CONF_BRIGHTNESS,
@@ -406,6 +431,27 @@ async def addressable_rainbow_effect_to_code(config, effect_id):
return var
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = cv.Schema(
{
cv.Optional(CONF_COLOR_BRIGHTNESS): cv.percentage,
cv.Optional(CONF_RED): cv.percentage,
cv.Optional(CONF_GREEN): cv.percentage,
cv.Optional(CONF_BLUE): cv.percentage,
cv.Optional(CONF_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_RANDOM, default=False): cv.boolean,
cv.Required(CONF_NUM_LEDS): cv.All(cv.uint32_t, cv.Range(min=1)),
cv.Optional(CONF_GRADIENT, default=False): cv.boolean,
}
)
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = _with_color_option(
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA,
CONF_COLOR_BRIGHTNESS,
CONF_RED,
CONF_GREEN,
CONF_BLUE,
)
@register_addressable_effect(
"addressable_color_wipe",
AddressableColorWipeEffect,
@@ -413,17 +459,7 @@ async def addressable_rainbow_effect_to_code(config, effect_id):
{
cv.Optional(
CONF_COLORS, default=[{CONF_NUM_LEDS: 1, CONF_RANDOM: True}]
): cv.ensure_list(
{
cv.Optional(CONF_RED, default=1.0): cv.percentage,
cv.Optional(CONF_GREEN, default=1.0): cv.percentage,
cv.Optional(CONF_BLUE, default=1.0): cv.percentage,
cv.Optional(CONF_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_RANDOM, default=False): cv.boolean,
cv.Required(CONF_NUM_LEDS): cv.All(cv.uint32_t, cv.Range(min=1)),
cv.Optional(CONF_GRADIENT, default=False): cv.boolean,
}
),
): cv.ensure_list(ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA),
cv.Optional(
CONF_ADD_LED_INTERVAL, default="0.1s"
): cv.positive_time_period_milliseconds,
@@ -434,19 +470,21 @@ async def addressable_color_wipe_effect_to_code(config, effect_id):
var = cg.new_Pvariable(effect_id, config[CONF_NAME])
cg.add(var.set_add_led_interval(config[CONF_ADD_LED_INTERVAL]))
cg.add(var.set_reverse(config[CONF_REVERSE]))
colors = [
cg.StructInitializer(
AddressableColorWipeEffectColor,
("r", int(round(color[CONF_RED] * 255))),
("g", int(round(color[CONF_GREEN] * 255))),
("b", int(round(color[CONF_BLUE] * 255))),
("w", int(round(color[CONF_WHITE] * 255))),
("random", color[CONF_RANDOM]),
("num_leds", color[CONF_NUM_LEDS]),
("gradient", color[CONF_GRADIENT]),
colors = []
for color in config.get(CONF_COLORS, []):
color_brightness = color[CONF_COLOR_BRIGHTNESS]
colors.append(
cg.StructInitializer(
AddressableColorWipeEffectColor,
("r", int(round(color[CONF_RED] * color_brightness * 255))),
("g", int(round(color[CONF_GREEN] * color_brightness * 255))),
("b", int(round(color[CONF_BLUE] * color_brightness * 255))),
("w", int(round(color[CONF_WHITE] * 255))),
("random", color[CONF_RANDOM]),
("num_leds", color[CONF_NUM_LEDS]),
("gradient", color[CONF_GRADIENT]),
)
)
for color in config.get(CONF_COLORS, [])
]
cg.add(var.set_colors(colors))
return var
@@ -0,0 +1,22 @@
esphome:
name: test-color-wipe-color
esp32:
board: esp32dev
framework:
type: arduino
light:
- platform: fastled_clockless
id: wipe_light
chipset: WS2811
pin: GPIO13
num_leds: 10
effects:
- addressable_color_wipe:
name: Wipe
colors:
- color: darkred
num_leds: 1
- red: 50%
num_leds: 1
@@ -0,0 +1,40 @@
"""Regression test: addressable_color_wipe's `color:` must scale by color_brightness.
The per-item schema tests in test_effect_color.py only check the validated config
dict; they would still pass if addressable_color_wipe_effect_to_code dropped
color_brightness on the floor (as it originally did), since a dark color's red/green/
blue are already peak-normalized to 1.0 by the time codegen sees them. Generate the
actual C++ and check the AddressableColorWipeEffectColor byte values instead, so a
regression in the scaling multiplication itself is caught.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.fixture
def main_cpp(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(
component_config_path("addressable_color_wipe_color_test.yaml")
)
def test_dark_color_name_is_scaled_by_color_brightness(main_cpp: str) -> None:
# color: darkred (0x8B0000) normalizes to r=1.0 but color_brightness=0x8B/0xFF,
# so the byte value must come back down to 0x8B (139), not 255.
assert ".r = 139," in main_cpp
assert ".g = 0," in main_cpp
assert ".b = 0," in main_cpp
def test_explicit_channel_is_unaffected(main_cpp: str) -> None:
# red: 50% with no color: or color_brightness: set must still scale by the
# default color_brightness of 1.0, i.e. come out as plain 50% of 255.
assert ".r = 128," in main_cpp
@@ -0,0 +1,69 @@
"""Tests for the `color` option in per-item effect color lists."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.light.effects import (
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA,
STROBE_COLOR_SCHEMA,
)
from esphome.const import CONF_BLUE, CONF_COLOR_BRIGHTNESS, CONF_GREEN, CONF_RED
def test_strobe_plain_defaults_full_level() -> None:
result = STROBE_COLOR_SCHEMA({"duration": "1s"})
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 1.0, 1.0)
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_strobe_color_name() -> None:
result = STROBE_COLOR_SCHEMA({"duration": "1s", "color": "darkred"})
assert "color" not in result
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 0.0, 0.0)
assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x8B / 0xFF)
def test_strobe_hex_color() -> None:
result = STROBE_COLOR_SCHEMA({"duration": "1s", "color": "0x223344"})
assert result[CONF_BLUE] == 1.0
assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x44 / 0xFF)
def test_strobe_explicit_channels_unaffected() -> None:
result = STROBE_COLOR_SCHEMA({"duration": "1s", "red": "50%"})
assert result[CONF_RED] == 0.5
assert (result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 1.0)
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_strobe_color_conflicts_with_rgb() -> None:
with pytest.raises(cv.Invalid, match="cannot be used with"):
STROBE_COLOR_SCHEMA({"duration": "1s", "color": "red", "red": "10%"})
def test_addressable_color_wipe_plain_defaults_full_level() -> None:
result = ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA({"num_leds": 1})
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 1.0, 1.0)
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_addressable_color_wipe_color_name() -> None:
result = ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA({"num_leds": 1, "color": "darkred"})
assert "color" not in result
assert (result[CONF_RED], result[CONF_GREEN], result[CONF_BLUE]) == (1.0, 0.0, 0.0)
assert result[CONF_COLOR_BRIGHTNESS] == pytest.approx(0x8B / 0xFF)
def test_addressable_color_wipe_explicit_channels_unaffected() -> None:
result = ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA({"num_leds": 1, "red": "50%"})
assert result[CONF_RED] == 0.5
assert result[CONF_COLOR_BRIGHTNESS] == 1.0
def test_addressable_color_wipe_color_conflicts_with_rgb() -> None:
with pytest.raises(cv.Invalid, match="cannot be used with"):
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA(
{"num_leds": 1, "color": "blue", "blue": "10%"}
)
@@ -21,6 +21,8 @@ light:
green: 0%
blue: 0%
num_leds: 1
- color: darkred
num_leds: 1
add_led_interval: 100ms
reverse: false
- addressable_scan:
+4
View File
@@ -429,6 +429,10 @@ light:
blue: 0%
duration: 1s
transition_length: 500ms
- state: true
color: darkred
duration: 1s
transition_length: 500ms
- platform: partition
id: test_addressable_transition
name: Addressable Transition Test