[light] Keep the effect list in a flash table (#20204)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
This commit is contained in:
J. Nick Koston
2026-10-09 15:55:09 -10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot]
parent 6b03d68fac
commit 41cc96bbb8
19 changed files with 207 additions and 80 deletions
@@ -3,6 +3,8 @@
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
#include "esphome/components/light/color_mode.h"
#include "esphome/components/light/light_effect.h"
#include "esphome/components/light/light_effect_names.h"
namespace esphome::api::benchmarks {
@@ -150,7 +152,17 @@ BENCHMARK(CalcAndEncode_ListEntitiesBinarySensorResponse);
// --- ListEntitiesLightResponse ---
static light::ColorModeMask light_color_modes;
static FixedVector<const char *> light_effects;
class BenchEffect : public light::LightEffect {
public:
explicit BenchEffect(const char *name) : LightEffect(name) {}
void apply() override {}
};
static BenchEffect rainbow_effect("Rainbow");
static BenchEffect strobe_effect("Strobe");
static light::LightEffect *const LIGHT_EFFECT_TABLE[] = {&rainbow_effect, &strobe_effect};
static const ConstVector<light::LightEffect *> light_effect_list(LIGHT_EFFECT_TABLE, 2);
static const light::LightEffectNames light_effects(light_effect_list);
static ListEntitiesLightResponse make_light_response() {
// Initialize static data on first call
@@ -158,10 +170,6 @@ static ListEntitiesLightResponse make_light_response() {
if (!initialized) {
light_color_modes.insert(light::ColorMode::RGB_WHITE);
light_color_modes.insert(light::ColorMode::COLOR_TEMPERATURE);
light_effects.init(3);
light_effects.push_back("None");
light_effects.push_back("Rainbow");
light_effects.push_back("Strobe");
initialized = true;
}
@@ -2,6 +2,14 @@
from collections.abc import Callable
from pathlib import Path
import re
import pytest
import esphome.codegen as cg
from esphome.components import light
from esphome.components.light import types as light_types
from esphome.core import ID, EsphomeError
def test_default_flash_length_and_empty_effects_are_not_emitted(
@@ -16,4 +24,20 @@ def test_default_flash_length_and_empty_effects_are_not_emitted(
assert "bare_light->set_flash_transition_length(" not in main_cpp
assert "bare_light->add_effects(" not in main_cpp
assert "fancy_light->set_flash_transition_length(500);" in main_cpp
assert "fancy_light->add_effects({" in main_cpp
call = re.search(r"fancy_light->add_effects\((\w+), (\d+)\);", main_cpp)
assert call is not None
# The effect pointers form a flash table; they are address constants, not constexpr
assert re.search(
rf"static light::LightEffect \* const {call.group(1)}\[\] PROGMEM = \{{[^}}]+\}};",
main_cpp,
)
def test_effect_assigned_in_setup_is_rejected() -> None:
"""A pointer assigned in setup() would make the flash table need dynamic init."""
dynamic_effect = cg.Pvariable(
ID("dynamic_effect", is_declaration=True, type=light_types.LightEffect),
cg.RawExpression("make_effect()"),
)
with pytest.raises(EsphomeError, match="dynamic_effect"):
light._effects_table([dynamic_effect])
+2 -1
View File
@@ -32,6 +32,7 @@ esphome:
- lambda: |-
const auto &effects = id(test_monochromatic_light).get_effects();
auto &same = id(test_monochromatic_light).get_effects();
const auto copy = id(test_monochromatic_light).get_effects(); // the view is copyable
uint32_t total = effects.size();
for (auto *effect : effects) {
ESP_LOGD("test", "Effect %s", effect->get_name().c_str());
@@ -39,7 +40,7 @@ esphome:
if (total > 0) {
ESP_LOGD("test", "First %s", effects.at(0)->get_name().c_str());
// Raw pointer iterators on purpose: external code binds std::find's result to `const auto *`
auto *it = std::find(same.begin(), same.end(), effects[0]);
auto *it = std::find(same.begin(), same.end(), copy[0]);
ESP_LOGD("test", "Index %d", (int) (it - same.begin()));
}
@@ -1,6 +1,5 @@
#include <gtest/gtest.h>
#include "esphome/components/light/light_effect.h"
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
@@ -18,36 +17,8 @@ class BrightnessOutput : public LightOutput {
void write_state(LightState *state) override {}
};
class NoopEffect : public LightEffect {
public:
using LightEffect::LightEffect;
void apply() override {}
};
// start_effect_() is where the uint32_t index is narrowed to the stored uint16_t.
class TestableLightState : public LightState {
public:
using LightState::LightState;
using LightState::start_effect_;
};
} // namespace
// add_effects() is public, so an external component can exceed the codegen cap on effect count;
// an index the uint16_t can't hold must be ignored rather than wrap onto another effect.
TEST(LightStateEffect, IndexAboveUint16IsIgnoredAndKeepsTheActiveEffect) {
BrightnessOutput output;
TestableLightState state(&output);
NoopEffect effect("Noop");
state.add_effects({&effect});
state.start_effect_(1);
ASSERT_EQ(state.get_current_effect_index(), 1u);
state.start_effect_(0x10000u); // unchecked narrowing wraps this to 0, which stops the effect
EXPECT_EQ(state.get_current_effect_index(), 1u);
}
// get_gamma_correct() reads the gamma codegen stores after the lookup table, rounded to two decimals.
TEST(LightStateGamma, ReadsTheGammaStoredWithTheTable) {
static constexpr GammaTable TABLE{{}, 280};
+41 -1
View File
@@ -4,7 +4,7 @@ import math
import pytest
from esphome import cpp_generator as cg, cpp_types as ct
from esphome.core import CORE, ID
from esphome.core import CORE, ID, EsphomeError
class TestExpressions:
@@ -829,6 +829,46 @@ class TestSharedProgmemArray:
assert str(a) != str(b)
assert sum("PROGMEM" in str(st) for st in CORE.global_statements) == 2
def test_is_static_pointer_only_for_placement_new(self) -> None:
CORE.config = {}
static = cg.new_Pvariable(ID("static_obj", is_declaration=True, type=ct.uint8))
dynamic = cg.Pvariable(
ID("dynamic_obj", is_declaration=True, type=ct.uint8),
cg.RawExpression("nullptr"),
)
assert cg.is_static_pointer(static)
assert not cg.is_static_pointer(dynamic)
def test_constexpr_and_const_arrays_are_not_shared(self) -> None:
CORE.config = {}
a = cg.shared_progmem_array("table", ct.uint8, [1], constexpr=False)
b = cg.shared_progmem_array("table", ct.uint8, [1])
assert str(a) != str(b)
def test_constexpr_false_rejects_dynamic_pointers(self) -> None:
CORE.config = {}
dynamic = cg.Pvariable(
ID("dynamic_obj", is_declaration=True, type=ct.uint8),
cg.RawExpression("nullptr"),
)
with pytest.raises(EsphomeError, match="dynamic_obj"):
cg.shared_progmem_array(
"table",
ct.uint8.operator("ptr"),
cg.ArrayInitializer(dynamic),
constexpr=False,
)
def test_constexpr_false_emits_a_const_array(self) -> None:
CORE.config = {}
cg.shared_progmem_array("table", ct.uint8, [1], constexpr=False)
(statement,) = (
str(st) for st in CORE.global_statements if "PROGMEM" in str(st)
)
assert statement.startswith(
"ESPHOME_FLASH_CONSTINIT static uint8_t const table[] PROGMEM = {1}"
)
def test_same_contents_different_type_are_separate(self) -> None:
CORE.config = {}
a = cg.shared_progmem_array("table", ct.uint8, [1])