diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 328d01a167..9ed183ea20 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -70,6 +70,7 @@ from .types import ( # noqa: F401 AddressableLightState, ChannelColors, ColorMode, + GammaTable, LightOutput, LightState, LightStateRTCState, @@ -85,6 +86,7 @@ CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True DOMAIN = "light" +CONF_GAMMA_TABLE_ID = "gamma_table_id" @dataclass @@ -140,18 +142,29 @@ def generate_gamma_table(gamma_correct: float) -> list[HexInt]: return [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)] -def _get_or_create_gamma_table(gamma_correct): +def gamma_table_initializer(gamma_correct: float) -> str: + """C++ initializer for a light::GammaTable: the lookup table, then gamma * 100.""" + lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(gamma_correct)) + # gamma_x100 is a uint16_t; platforms that redefine gamma_correct leave it unbounded, so saturate here + return f"{{{{{lut}}}, {min(0xFFFF, round(gamma_correct * 100))}}}" + + +def _get_or_create_gamma_table(gamma_correct: float, table_id: ID) -> cg.RawExpression: data = _get_data() if gamma_correct in data.gamma_tables: return data.gamma_tables[gamma_correct] - forward = generate_gamma_table(gamma_correct) - - gamma_str = f"{gamma_correct}".replace(".", "_") - fwd_id = ID(f"gamma_{gamma_str}_fwd", is_declaration=True, type=cg.uint16) - fwd_arr = cg.progmem_array(fwd_id, forward) - data.gamma_tables[gamma_correct] = fwd_arr - return fwd_arr + # table_id is generated and resolved against every declared ID, so it can't collide with a + # YAML ID; lights sharing a gamma reuse the first light's table. + cg.add( + cg.RawStatement( + f"static constexpr light::GammaTable {table_id} PROGMEM = " + f"{gamma_table_initializer(gamma_correct)};" + ) + ) + table = cg.RawExpression(f"&{table_id}") + data.gamma_tables[gamma_correct] = table + return table def find_effect_index(effects: list, effect_name: str) -> int | None: @@ -357,6 +370,7 @@ LIGHT_SCHEMA = ( .extend( { cv.GenerateID(): cv.declare_id(LightState), + cv.GenerateID(CONF_GAMMA_TABLE_ID): cv.declare_id(GammaTable), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id( mqtt.MQTTJSONLightComponent ), @@ -553,14 +567,14 @@ async def setup_light_core_(light_var, config, output_var): ) is not None and flash_transition_length != cv.time_period( DEFAULT_FLASH_TRANSITION_LENGTH ): + cg.add_define("USE_LIGHT_FLASH_TRANSITION_LENGTH") cg.add(light_var.set_flash_transition_length(flash_transition_length)) # Setting an interval opts this light in and compiles the feature in if (interval := config.get(CONF_TRANSITION_STATE_PUBLISH_INTERVAL)) is not None: cg.add(light_var.set_transition_state_publish_interval(interval)) cg.add_define("USE_LIGHT_TRANSITION_PUBLISH_INTERVAL") if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: - cg.add(light_var.set_gamma_correct(gamma_correct)) - fwd_arr = _get_or_create_gamma_table(gamma_correct) + fwd_arr = _get_or_create_gamma_table(gamma_correct, config[CONF_GAMMA_TABLE_ID]) cg.add(light_var.set_gamma_table(fwd_arr)) cg.add_define("USE_LIGHT_GAMMA_LUT") effects = await cg.build_registry_list( diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 535def0ba0..b12ec189b9 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -545,12 +545,18 @@ async def addressable_flicker_effect_to_code(config, effect_id): return var +# LightState stores the active effect index in a uint16_t +MAX_EFFECTS = 65535 + + def validate_effects(allowed_effects): @schema_extractor("effects") def validator(value): if value == SCHEMA_EXTRACT: return (allowed_effects, EFFECTS_REGISTRY) + if isinstance(value, list) and len(value) > MAX_EFFECTS: + raise cv.Invalid(f"A light supports at most {MAX_EFFECTS} effects") value = cv.validate_registry("effect", EFFECTS_REGISTRY)(value) errors = [] names = set() diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 4eb5208c96..0295fb36b8 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -5,6 +5,12 @@ namespace esphome::light { +/// A gamma curve as codegen emits it into PROGMEM: the lookup table and the gamma it was built from +struct GammaTable { + uint16_t lut[256]; + uint16_t gamma_x100; +}; + /// Binary search a monotonically increasing uint16[256] PROGMEM table. /// Returns the largest index where table[index] <= target. inline uint8_t gamma_table_reverse_search(const uint16_t *table, uint16_t target) { diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9cbe9639db..1b9a7f94c1 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -9,6 +9,8 @@ #include "light_output.h" #include "transformers.h" +#include + namespace esphome::light { static const char *const TAG = "light"; @@ -94,10 +96,17 @@ void LightState::dump_config() { ESP_LOGCONFIG(TAG, "Light '%s'", this->get_name().c_str()); auto traits = this->get_traits(); if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) { +#ifdef USE_LIGHT_GAMMA_LUT + // Read the stored gamma * 100 directly so dump_config does not pull in get_gamma_correct() + const unsigned gamma_x100 = + this->gamma_table_ != nullptr ? progmem_read_uint16(&this->gamma_table_->gamma_x100) : 0; +#else + const unsigned gamma_x100 = 0; +#endif ESP_LOGCONFIG(TAG, " Default Transition Length: %.1fs\n" - " Gamma Correct: %.2f", - this->default_transition_length_ / 1e3f, this->gamma_correct_); + " Gamma Correct: %u.%02u", + this->default_transition_length_ / 1e3f, gamma_x100 / 100, gamma_x100 % 100); #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL // The define is build wide; only lights that set the option have an interval if (this->transition_state_publish_interval_ != 0) { @@ -296,6 +305,14 @@ void LightState::current_values_as_ct(float *color_temperature, float *white_bri *white_brightness = this->gamma_correct_lut(*white_brightness); } +float LightState::get_gamma_correct() const { +#ifdef USE_LIGHT_GAMMA_LUT + if (this->gamma_table_ != nullptr) + return progmem_read_uint16(&this->gamma_table_->gamma_x100) * 0.01f; +#endif // USE_LIGHT_GAMMA_LUT + return 0.0f; +} + #ifdef USE_LIGHT_GAMMA_LUT float LightState::gamma_correct_lut(float value) const { if (value <= 0.0f) @@ -307,10 +324,10 @@ float LightState::gamma_correct_lut(float value) const { float scaled = value * 255.0f; auto idx = static_cast(scaled); if (idx >= 255) - return progmem_read_uint16(&this->gamma_table_[255]) / 65535.0f; + return progmem_read_uint16(&this->gamma_table_->lut[255]) / 65535.0f; float frac = scaled - idx; - float a = progmem_read_uint16(&this->gamma_table_[idx]); - float b = progmem_read_uint16(&this->gamma_table_[idx + 1]); + float a = progmem_read_uint16(&this->gamma_table_->lut[idx]); + float b = progmem_read_uint16(&this->gamma_table_->lut[idx + 1]); return (a + frac * (b - a)) / 65535.0f; } float LightState::gamma_uncorrect_lut(float value) const { @@ -321,12 +338,12 @@ float LightState::gamma_uncorrect_lut(float value) const { if (this->gamma_table_ == nullptr) return value; uint16_t target = static_cast(value * 65535.0f); - uint8_t lo = gamma_table_reverse_search(this->gamma_table_, target); + uint8_t lo = gamma_table_reverse_search(this->gamma_table_->lut, target); if (lo >= 255) return 1.0f; // Interpolate between lo and lo+1 - uint16_t a = progmem_read_uint16(&this->gamma_table_[lo]); - uint16_t b = progmem_read_uint16(&this->gamma_table_[lo + 1]); + uint16_t a = progmem_read_uint16(&this->gamma_table_->lut[lo]); + uint16_t b = progmem_read_uint16(&this->gamma_table_->lut[lo + 1]); if (b == a) return lo / 255.0f; float frac = static_cast(target - a) / static_cast(b - a); @@ -335,11 +352,14 @@ float LightState::gamma_uncorrect_lut(float value) const { #endif // USE_LIGHT_GAMMA_LUT void LightState::start_effect_(uint32_t effect_index) { + // An external add_effects() can exceed the codegen cap; ignore an index the uint16_t can't hold + if (effect_index > std::numeric_limits::max()) + return; this->stop_effect_(); if (effect_index == 0) return; - this->active_effect_index_ = effect_index; + this->active_effect_index_ = static_cast(effect_index); auto *effect = this->get_active_effect_(); effect->start_internal(); // Enable loop while effect is active @@ -435,7 +455,7 @@ void LightState::save_remote_values_() { saved.color_temp = this->remote_values.get_color_temperature(); saved.cold_white = this->remote_values.get_cold_white(); saved.warm_white = this->remote_values.get_warm_white(); - saved.effect = this->active_effect_index_; + saved.effect = static_cast(this->active_effect_index_); // the saved layout stays uint32_t this->rtc_.save(&saved); } diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 4f7d3c9330..f20fecc8dd 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -6,6 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/string_ref.h" #include "light_call.h" +#include "esp_color_correction.h" #include "light_color_values.h" #include "light_effect.h" #include "light_traits.h" @@ -151,15 +152,28 @@ class LightState : public EntityBase, public Component { } uint32_t get_default_transition_length() const { return this->default_transition_length_; } - /// Set the flash transition length +#ifdef USE_LIGHT_FLASH_TRANSITION_LENGTH + /// Set the flash transition length; only compiled in when a light configures one void set_flash_transition_length(uint32_t flash_transition_length) { this->flash_transition_length_ = flash_transition_length; } uint32_t get_flash_transition_length() const { return this->flash_transition_length_; } +#else + // Remove before 2027.4.0 + ESPDEPRECATED("set_flash_transition_length() does nothing unless flash_transition_length is set in YAML. Removed in " + "2027.4.0", + "2026.10.0") + void set_flash_transition_length(uint32_t flash_transition_length) {} + uint32_t get_flash_transition_length() const { return 0; } +#endif - /// Set the gamma correction factor - void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } - float get_gamma_correct() const { return this->gamma_correct_; } + // Remove before 2027.4.0 + ESPDEPRECATED("set_gamma_correct() does nothing; gamma is fixed at build time by gamma_correct in YAML. Removed in " + "2027.4.0", + "2026.10.0") + void set_gamma_correct(float gamma_correct) {} + /// The gamma correction factor, read from the entry after the gamma lookup table; 0 without one + float get_gamma_correct() const; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL void set_transition_state_publish_interval(uint32_t transition_state_publish_interval) { @@ -169,11 +183,11 @@ class LightState : public EntityBase, public Component { #endif #ifdef USE_LIGHT_GAMMA_LUT - /// Set pre-computed gamma forward lookup table (256-entry uint16 PROGMEM array) - void set_gamma_table(const uint16_t *forward) { this->gamma_table_ = forward; } + /// Set the pre-computed PROGMEM gamma curve + void set_gamma_table(const GammaTable *table) { this->gamma_table_ = table; } - /// Get the forward gamma lookup table - const uint16_t *get_gamma_table() const { return this->gamma_table_; } + /// Get the forward gamma lookup table, 256 PROGMEM entries + const uint16_t *get_gamma_table() const { return this->gamma_table_ != nullptr ? this->gamma_table_->lut : nullptr; } /// Apply gamma correction using the pre-computed forward LUT float gamma_correct_lut(float value) const; @@ -362,31 +376,31 @@ class LightState : public EntityBase, public Component { /// Values live in flash as function body; no per-instance data storage beyond this pointer. void (*state_callback_)(LightStateRTCState &, bool restored){nullptr}; - /// Value for storing the index of the currently active effect. 0 if no effect is active - uint32_t active_effect_index_{}; /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; +#ifdef USE_LIGHT_FLASH_TRANSITION_LENGTH /// Transition length to use for flash transitions. uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py +#endif #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL uint32_t transition_state_publish_interval_{0}; uint32_t last_transition_state_publish_{0}; #endif - /// Gamma correction factor for the light. - float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT - const uint16_t *gamma_table_{nullptr}; + const GammaTable *gamma_table_{nullptr}; #endif // USE_LIGHT_GAMMA_LUT + /// 1-based index of the active effect, 0 if none; codegen caps effects at MAX_EFFECTS in effects.py + uint16_t active_effect_index_{}; /// Whether the light value should be written in the next cycle. - bool next_write_{true}; + bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word // for effects, true if a transformer (transition) is active. - bool is_transformer_active_{false}; + bool is_transformer_active_ : 1 {false}; /// Whether this light persists its state to preferences at all. - bool save_enabled_{false}; + bool save_enabled_ : 1 {false}; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL /// True while the active transformer publishes current_values on an interval from loop(). - bool transition_publish_enabled_{false}; + bool transition_publish_enabled_ : 1 {false}; #endif }; diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index ceb974f26d..7a95920a74 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -4,6 +4,7 @@ import esphome.codegen as cg # Base light_ns = cg.esphome_ns.namespace("light") LightState = light_ns.class_("LightState", cg.EntityBase, cg.Component) +GammaTable = light_ns.struct("GammaTable") LightStateRef = LightState.operator("ref") AddressableLightState = light_ns.class_("AddressableLightState", LightState) LightOutput = light_ns.class_("LightOutput") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 29b686e03c..757e3cfe79 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -89,6 +89,7 @@ #define USE_JSON #define USE_RADIO_FREQUENCY #define USE_LIGHT +#define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LOCK diff --git a/tests/benchmarks/components/light/__init__.py b/tests/benchmarks/components/light/__init__.py index 233a3c246e..bffaf0febf 100644 --- a/tests/benchmarks/components/light/__init__.py +++ b/tests/benchmarks/components/light/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components.light import generate_gamma_table +from esphome.components.light import gamma_table_initializer from tests.testing_helpers import ComponentManifestOverride @@ -12,15 +12,13 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code(config): await original_to_code(config) cg.add_define("USE_LIGHT_GAMMA_LUT") - # Use the light component's own generate_gamma_table() so the + # Use the light component's own gamma_table_initializer() so the # benchmark stays in sync with any formula changes. - forward = generate_gamma_table(2.8) - values = ", ".join(f"0x{int(v):04X}" for v in forward) - # Use extern-visible (non-static) array so the benchmark .cpp - # can reference it via extern declaration. + # Extern-visible (non-static) so the benchmark .cpp can reference it. cg.add_global( cg.RawStatement( - f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};" + "extern const esphome::light::GammaTable bench_gamma_2_8 PROGMEM = " + f"{gamma_table_initializer(2.8)};" ) ) diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp index cc6871654d..e102d32371 100644 --- a/tests/benchmarks/components/light/bench_light_call.cpp +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -4,8 +4,8 @@ #include "esphome/components/light/light_state.h" // Gamma 2.8 forward LUT generated by the light component's Python codegen -// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table()) -extern const uint16_t bench_gamma_2_8_fwd[256]; +// (see tests/benchmarks/components/light/__init__.py which calls gamma_table_initializer()) +extern const esphome::light::GammaTable bench_gamma_2_8; namespace esphome::benchmarks { @@ -37,8 +37,7 @@ static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { output.traits_.set_max_mireds(500.0f); light.configure("test_light"); light.set_default_transition_length(0); - light.set_gamma_correct(2.8f); - light.set_gamma_table(bench_gamma_2_8_fwd); + light.set_gamma_table(&bench_gamma_2_8); } // --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- diff --git a/tests/component_tests/light/test_light_state_layout.py b/tests/component_tests/light/test_light_state_layout.py new file mode 100644 index 0000000000..2d6c6e1523 --- /dev/null +++ b/tests/component_tests/light/test_light_state_layout.py @@ -0,0 +1,37 @@ +"""Limits and flash layout that let LightState stay small.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.light.effects import ( + MAX_EFFECTS, + MONOCHROMATIC_EFFECTS, + validate_effects, +) + + +def _effects(count: int) -> list[dict[str, dict[str, str]]]: + return [{"pulse": {"name": f"Pulse {i}"}} for i in range(count)] + + +def test_rejects_more_effects_than_the_index_holds() -> None: + with pytest.raises(cv.Invalid, match=f"at most {MAX_EFFECTS} effects"): + validate_effects(MONOCHROMATIC_EFFECTS)(_effects(MAX_EFFECTS + 1)) + + +def test_accepts_a_normal_effect_list() -> None: + assert len(validate_effects(MONOCHROMATIC_EFFECTS)(_effects(3))) == 3 + + +def test_gamma_table_initializer_holds_the_lut_then_gamma_times_100() -> None: + from esphome.components.light import gamma_table_initializer, generate_gamma_table + + init = gamma_table_initializer(2.8) + lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(2.8)) + assert init == f"{{{{{lut}}}, 280}}" + + +def test_gamma_table_initializer_saturates_gamma_that_overflows_uint16() -> None: + from esphome.components.light import gamma_table_initializer + + assert gamma_table_initializer(1000.0).endswith(", 65535}") diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py index d5cdb70c9c..2d5eb9223d 100644 --- a/tests/components/light/__init__.py +++ b/tests/components/light/__init__.py @@ -8,6 +8,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_WEBSERVER") # api_connection.cpp reports the port whenever USE_WEBSERVER is set cg.add_define("USE_WEBSERVER_PORT", 80) + # The gamma table and its stored gamma are behind this define + cg.add_define("USE_LIGHT_GAMMA_LUT") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 9e3242f9a1..15829b1463 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -254,6 +254,7 @@ light: state: INITIAL brightness: INITIAL - platform: monochromatic + flash_transition_length: 100ms id: test_monochromatic_light name: Monochromatic Light output: test_ledc_1 diff --git a/tests/components/light/test_light_state.cpp b/tests/components/light/test_light_state.cpp new file mode 100644 index 0000000000..85bfbd09ef --- /dev/null +++ b/tests/components/light/test_light_state.cpp @@ -0,0 +1,68 @@ +#include + +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +class BrightnessOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + 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}; + BrightnessOutput output; + LightState state(&output); + state.set_gamma_table(&TABLE); + EXPECT_FLOAT_EQ(state.get_gamma_correct(), 2.8f); + EXPECT_EQ(state.get_gamma_table(), TABLE.lut); +} + +TEST(LightStateGamma, IsZeroWithoutATable) { + BrightnessOutput output; + LightState state(&output); + EXPECT_FLOAT_EQ(state.get_gamma_correct(), 0.0f); + EXPECT_EQ(state.get_gamma_table(), nullptr); +} + +} // namespace esphome::light::testing