From 4a528b39afb5a3500400b7dd3d91f2edf5aabb1b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 22:43:25 -0500 Subject: [PATCH] [light] Keep strobe and color wipe colors in flash tables --- .../light/addressable_light_effect.h | 46 +++++++--- esphome/components/light/base_light_effects.h | 38 ++++++-- esphome/components/light/effects.py | 86 +++++++++++++------ .../light/config/effect_color_tables.yaml | 44 ++++++++++ .../light/test_effect_color_tables.py | 31 +++++++ 5 files changed, 198 insertions(+), 47 deletions(-) create mode 100644 tests/component_tests/light/config/effect_color_tables.yaml create mode 100644 tests/component_tests/light/test_effect_color_tables.py diff --git a/esphome/components/light/addressable_light_effect.h b/esphome/components/light/addressable_light_effect.h index 283b037acad..fe805476a4a 100644 --- a/esphome/components/light/addressable_light_effect.h +++ b/esphome/components/light/addressable_light_effect.h @@ -3,6 +3,7 @@ #include #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/components/light/light_state.h" #include "esphome/components/light/addressable_light.h" @@ -101,17 +102,24 @@ class AddressableRainbowLightEffect : public AddressableLightEffect { uint16_t width_{50}; }; +/// One wipe color. Codegen keeps the colors in a flash table, so it is read with progmem_memcpy. struct AddressableColorWipeEffectColor { uint8_t r, g, b, w; - bool random; - size_t num_leds; + uint8_t random_slot; ///< index into the effect's random colors, or NO_RANDOM_SLOT bool gradient; + uint32_t num_leds; }; +static constexpr uint8_t NO_RANDOM_SLOT = 0xFF; class AddressableColorWipeEffect : public AddressableLightEffect { public: explicit AddressableColorWipeEffect(const char *name) : AddressableLightEffect(name) {} - void set_colors(const std::initializer_list &colors) { this->colors_ = colors; } + /// Codegen only: point at the generated flash table of colors, which must outlive the effect. + /// Random colors keep their current value in a RAM array, one slot per random entry. + void set_colors(const AddressableColorWipeEffectColor *colors, size_t count, Color *random_colors) { + this->colors_ = {colors, count}; + this->random_colors_ = random_colors; + } void set_add_led_interval(uint32_t add_led_interval) { this->add_led_interval_ = add_led_interval; } void set_reverse(bool reverse) { this->reverse_ = reverse; } void apply(AddressableLight &it, const Color ¤t_color) override { @@ -124,12 +132,11 @@ class AddressableColorWipeEffect : public AddressableLightEffect { } else { it.shift_right(1); } - const AddressableColorWipeEffectColor &color = this->colors_[this->at_color_]; - Color esp_color = Color(color.r, color.g, color.b, color.w); + const AddressableColorWipeEffectColor color = this->entry_(this->at_color_); + Color esp_color = this->color_of_(color); if (color.gradient) { size_t next_color_index = (this->at_color_ + 1) % this->colors_.size(); - const AddressableColorWipeEffectColor &next_color = this->colors_[next_color_index]; - const Color next_esp_color = Color(next_color.r, next_color.g, next_color.b, next_color.w); + const Color next_esp_color = this->color_of_(this->entry_(next_color_index)); uint8_t gradient = 255 * ((float) this->leds_added_ / color.num_leds); esp_color = esp_color.gradient(next_esp_color, gradient); } @@ -141,19 +148,32 @@ class AddressableColorWipeEffect : public AddressableLightEffect { if (++this->leds_added_ >= color.num_leds) { this->leds_added_ = 0; this->at_color_ = (this->at_color_ + 1) % this->colors_.size(); - AddressableColorWipeEffectColor &new_color = this->colors_[this->at_color_]; - if (new_color.random) { + const AddressableColorWipeEffectColor new_color = this->entry_(this->at_color_); + if (new_color.random_slot != NO_RANDOM_SLOT) { Color c = Color::random_color(); - new_color.r = c.r; - new_color.g = c.g; - new_color.b = c.b; + Color &slot = this->random_colors_[new_color.random_slot]; + slot.r = c.r; + slot.g = c.g; + slot.b = c.b; } } it.schedule_show(); } protected: - FixedVector colors_; + AddressableColorWipeEffectColor entry_(size_t index) const { + AddressableColorWipeEffectColor entry; + progmem_memcpy(&entry, &this->colors_[index], sizeof(entry)); + return entry; + } + Color color_of_(const AddressableColorWipeEffectColor &entry) const { + if (entry.random_slot != NO_RANDOM_SLOT) + return this->random_colors_[entry.random_slot]; + return Color(entry.r, entry.g, entry.b, entry.w); + } + + ConstVector colors_; + Color *random_colors_{nullptr}; size_t at_color_{0}; uint32_t last_add_{0}; uint32_t add_led_interval_{}; diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index e32dbde9521..935ccddb80f 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -3,6 +3,7 @@ #include #include "esphome/core/automation.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "light_effect.h" @@ -154,10 +155,21 @@ class AutomationLightEffect : public LightEffect { Trigger<> trig_; }; +/// One strobe step. Codegen keeps the steps in a flash table, so it is read with progmem_memcpy. struct StrobeLightEffectColor { - LightColorValues color; + float state; + float brightness; + float color_brightness; + float red; + float green; + float blue; + float white; + float color_temperature; + float cold_white; + float warm_white; uint32_t duration; uint32_t transition_length; + ColorMode color_mode; }; class StrobeLightEffect : public LightEffect { @@ -166,19 +178,22 @@ class StrobeLightEffect : public LightEffect { void start() override { // Place the cycle at the end of the last color, so the first apply() switches straight to the first color this->at_color_ = this->colors_.size() - 1; - this->last_switch_ = millis() - this->colors_.back().duration; + this->last_switch_ = millis() - this->duration_(this->at_color_); } void apply() override { const uint32_t now = millis(); - if (now - this->last_switch_ < this->colors_[this->at_color_].duration) + if (now - this->last_switch_ < this->duration_(this->at_color_)) return; // Switch to next color this->at_color_ = (this->at_color_ + 1) % this->colors_.size(); - auto color = this->colors_[this->at_color_].color; + StrobeLightEffectColor step; + progmem_memcpy(&step, &this->colors_[this->at_color_], sizeof(step)); + LightColorValues color(step.color_mode, step.state, step.brightness, step.color_brightness, step.red, step.green, + step.blue, step.white, step.color_temperature, step.cold_white, step.warm_white); auto call = this->state_->turn_on(); - call.from_light_color_values(this->colors_[this->at_color_].color); + call.from_light_color_values(color); if (!color.is_on()) { // Don't turn the light off, otherwise the light effect will be stopped @@ -187,15 +202,22 @@ class StrobeLightEffect : public LightEffect { } call.set_publish(false); call.set_save(false); - call.set_transition_length_if_supported(this->colors_[this->at_color_].transition_length); + call.set_transition_length_if_supported(step.transition_length); call.perform(); this->last_switch_ = now; } - void set_colors(const std::initializer_list &colors) { this->colors_ = colors; } + /// Codegen only: point at the generated flash table of steps, which must outlive the effect. + void set_colors(const StrobeLightEffectColor *colors, size_t count) { this->colors_ = {colors, count}; } protected: - FixedVector colors_; + uint32_t duration_(size_t index) const { + uint32_t duration; + progmem_memcpy(&duration, &this->colors_[index].duration, sizeof(duration)); + return duration; + } + + ConstVector colors_; uint32_t last_switch_{0}; size_t at_color_{0}; }; diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index dc6266b2fd2..6c822471a48 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -52,7 +52,6 @@ from .types import ( ColorMode, FlickerLightEffect, LambdaLightEffect, - LightColorValues, LightStateRef, PulseLightEffect, RandomLightEffect, @@ -351,28 +350,26 @@ async def strobe_effect_to_code(config, effect_id): colors = [ cg.StructInitializer( StrobeLightEffectColor, - ( - "color", - LightColorValues( - color.get(CONF_COLOR_MODE, ColorMode.UNKNOWN), - color[CONF_STATE], - color[CONF_BRIGHTNESS], - color[CONF_COLOR_BRIGHTNESS], - color[CONF_RED], - color[CONF_GREEN], - color[CONF_BLUE], - color[CONF_WHITE], - color.get(CONF_COLOR_TEMPERATURE, 0.0), - color[CONF_COLD_WHITE], - color[CONF_WARM_WHITE], - ), - ), + ("state", color[CONF_STATE]), + ("brightness", color[CONF_BRIGHTNESS]), + ("color_brightness", color[CONF_COLOR_BRIGHTNESS]), + ("red", color[CONF_RED]), + ("green", color[CONF_GREEN]), + ("blue", color[CONF_BLUE]), + ("white", color[CONF_WHITE]), + ("color_temperature", color.get(CONF_COLOR_TEMPERATURE, 0.0)), + ("cold_white", color[CONF_COLD_WHITE]), + ("warm_white", color[CONF_WARM_WHITE]), ("duration", color[CONF_DURATION]), ("transition_length", color[CONF_TRANSITION_LENGTH]), + ("color_mode", color.get(CONF_COLOR_MODE, ColorMode.UNKNOWN)), ) for color in config.get(CONF_COLORS, []) ] - cg.add(var.set_colors(colors)) + table = cg.shared_progmem_array( + "light_strobe_colors", StrobeLightEffectColor, cg.ArrayInitializer(*colors) + ) + cg.add(var.set_colors(table, len(colors))) return var @@ -431,6 +428,16 @@ async def addressable_rainbow_effect_to_code(config, effect_id): return var +# Matches NO_RANDOM_SLOT in addressable_light_effect.h +NO_RANDOM_SLOT = 0xFF + + +def _validate_random_slots(colors: list[ConfigType]) -> list[ConfigType]: + if sum(1 for color in colors if color[CONF_RANDOM]) >= NO_RANDOM_SLOT: + raise cv.Invalid(f"At most {NO_RANDOM_SLOT - 1} colors can be random") + return colors + + ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = cv.Schema( { cv.Optional(CONF_COLOR_BRIGHTNESS): cv.percentage, @@ -459,7 +466,11 @@ ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = _with_color_option( { cv.Optional( CONF_COLORS, default=[{CONF_NUM_LEDS: 1, CONF_RANDOM: True}] - ): cv.ensure_list(ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA), + ): cv.All( + cv.ensure_list(ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA), + cv.Length(min=1), + _validate_random_slots, + ), cv.Optional( CONF_ADD_LED_INTERVAL, default="0.1s" ): cv.positive_time_period_milliseconds, @@ -471,21 +482,44 @@ async def addressable_color_wipe_effect_to_code(config, effect_id): cg.add(var.set_add_led_interval(config[CONF_ADD_LED_INTERVAL])) cg.add(var.set_reverse(config[CONF_REVERSE])) colors = [] + random_colors = [] for color in config.get(CONF_COLORS, []): color_brightness = color[CONF_COLOR_BRIGHTNESS] + rgbw = ( + int(round(color[CONF_RED] * color_brightness * 255)), + int(round(color[CONF_GREEN] * color_brightness * 255)), + int(round(color[CONF_BLUE] * color_brightness * 255)), + int(round(color[CONF_WHITE] * 255)), + ) + random_slot = NO_RANDOM_SLOT + if color[CONF_RANDOM]: + random_slot = len(random_colors) + random_colors.append(rgbw) 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]), + ("r", rgbw[0]), + ("g", rgbw[1]), + ("b", rgbw[2]), + ("w", rgbw[3]), + ("random_slot", random_slot), ("gradient", color[CONF_GRADIENT]), + ("num_leds", color[CONF_NUM_LEDS]), ) ) - cg.add(var.set_colors(colors)) + table = cg.shared_progmem_array( + "light_color_wipe_colors", + AddressableColorWipeEffectColor, + cg.ArrayInitializer(*colors), + ) + random_storage = cg.nullptr + if random_colors: + # One RAM slot per random entry; it starts at the configured color, as before + name = f"light__{effect_id.id}__random_colors" + values = ", ".join(f"Color({r}, {g}, {b}, {w})" for r, g, b, w in random_colors) + cg.add_global(cg.RawStatement(f"static Color {name}[] = {{{values}}};")) + random_storage = cg.RawExpression(name) + cg.add(var.set_colors(table, len(colors), random_storage)) return var diff --git a/tests/component_tests/light/config/effect_color_tables.yaml b/tests/component_tests/light/config/effect_color_tables.yaml new file mode 100644 index 00000000000..77b0b9144a0 --- /dev/null +++ b/tests/component_tests/light/config/effect_color_tables.yaml @@ -0,0 +1,44 @@ +esphome: + name: test-effect-color-tables + +esp32: + board: esp32dev + framework: + type: arduino + +light: + - platform: fastled_clockless + id: strip_a + chipset: WS2811 + pin: GPIO13 + num_leds: 10 + effects: + - strobe: + name: Strobe A + colors: + - state: true + duration: 500ms + - state: false + duration: 250ms + - addressable_color_wipe: + name: Wipe + colors: + - red: 100% + green: 0% + blue: 0% + num_leds: 2 + - random: true + num_leds: 1 + - platform: fastled_clockless + id: strip_b + chipset: WS2811 + pin: GPIO14 + num_leds: 10 + effects: + - strobe: + name: Strobe B + colors: + - state: true + duration: 500ms + - state: false + duration: 250ms diff --git a/tests/component_tests/light/test_effect_color_tables.py b/tests/component_tests/light/test_effect_color_tables.py new file mode 100644 index 00000000000..ac43e006dc6 --- /dev/null +++ b/tests/component_tests/light/test_effect_color_tables.py @@ -0,0 +1,31 @@ +"""Strobe and addressable_color_wipe colors live in flash tables; random wipe colors get RAM slots.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_effect_colors_use_flash_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("effect_color_tables.yaml")) + + assert ( + "static constexpr light::StrobeLightEffectColor light_strobe_colors[] PROGMEM" + in main_cpp + ) + # Both strobes use the same steps, so they share one table + assert main_cpp.count("->set_colors(light_strobe_colors, 2);") == 2 + assert re.search( + r"static constexpr light::AddressableColorWipeEffectColor light_color_wipe_colors\w*\[\] PROGMEM", + main_cpp, + ) + # Only the random entry gets a RAM slot, starting at its configured color + assert re.search( + r"static Color light__\w+__random_colors\[\] = \{Color\(", main_cpp + ) + assert ".random_slot = 0," in main_cpp + assert ".random_slot = 255," in main_cpp