[light] Shrink LightState by 12 bytes per light (#19711)

This commit is contained in:
J. Nick Koston
2026-09-27 11:53:03 +02:00
committed by GitHub
parent ea4754e798
commit 2844236e77
13 changed files with 215 additions and 48 deletions
+24 -10
View File
@@ -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(
+6
View File
@@ -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()
@@ -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) {
+30 -10
View File
@@ -9,6 +9,8 @@
#include "light_output.h"
#include "transformers.h"
#include <limits>
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<uint8_t>(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<uint16_t>(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<float>(target - a) / static_cast<float>(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<uint16_t>::max())
return;
this->stop_effect_();
if (effect_index == 0)
return;
this->active_effect_index_ = effect_index;
this->active_effect_index_ = static_cast<uint16_t>(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<uint32_t>(this->active_effect_index_); // the saved layout stays uint32_t
this->rtc_.save(&saved);
}
+31 -17
View File
@@ -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
};
+1
View File
@@ -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")
+1
View File
@@ -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
@@ -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)};"
)
)
@@ -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) ---
@@ -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}")
+2
View File
@@ -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"]
+1
View File
@@ -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
@@ -0,0 +1,68 @@
#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"
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