mirror of
https://github.com/esphome/esphome.git
synced 2026-10-03 09:46:03 +00:00
[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:
co-authored by
Claude Sonnet 5
J. Nick Koston
parent
0ed997da92
commit
bfb7e76b2e
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user