diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index b12ec189b9..dc6266b2fd 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -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 diff --git a/tests/component_tests/light/config/addressable_color_wipe_color_test.yaml b/tests/component_tests/light/config/addressable_color_wipe_color_test.yaml new file mode 100644 index 0000000000..fc1e80688f --- /dev/null +++ b/tests/component_tests/light/config/addressable_color_wipe_color_test.yaml @@ -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 diff --git a/tests/component_tests/light/test_addressable_color_wipe_color.py b/tests/component_tests/light/test_addressable_color_wipe_color.py new file mode 100644 index 0000000000..f1dd44a32c --- /dev/null +++ b/tests/component_tests/light/test_addressable_color_wipe_color.py @@ -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 diff --git a/tests/component_tests/light/test_effect_color.py b/tests/component_tests/light/test_effect_color.py new file mode 100644 index 0000000000..52e4e440dd --- /dev/null +++ b/tests/component_tests/light/test_effect_color.py @@ -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%"} + ) diff --git a/tests/components/fastled_clockless/common.yaml b/tests/components/fastled_clockless/common.yaml index a7ce7ed280..98bc65f4dd 100644 --- a/tests/components/fastled_clockless/common.yaml +++ b/tests/components/fastled_clockless/common.yaml @@ -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: diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 046acef9ce..3cf2c59223 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -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