[light] Replace initial_state storage with flash-resident callback (#15133)

This commit is contained in:
J. Nick Koston
2026-03-24 14:03:18 -10:00
committed by GitHub
parent 752fe30332
commit 9fb5b6aa15
5 changed files with 97 additions and 9 deletions
+10 -2
View File
@@ -38,7 +38,7 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WHITE,
)
from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.cpp_generator import MockObjClass
@@ -262,6 +262,8 @@ async def setup_light_core_(light_var, config, output_var):
cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE]))
if (initial_state_config := config.get(CONF_INITIAL_STATE)) is not None:
# Emit a stateless lambda that constructs the initial state — values live
# in flash as code, not stored in the LightState object (~40 bytes saved).
initial_state = LightStateRTCState(
initial_state_config.get(CONF_COLOR_MODE, ColorMode.UNKNOWN),
initial_state_config.get(CONF_STATE, False),
@@ -275,7 +277,13 @@ async def setup_light_core_(light_var, config, output_var):
initial_state_config.get(CONF_COLD_WHITE, 1.0),
initial_state_config.get(CONF_WARM_WHITE, 1.0),
)
cg.add(light_var.set_initial_state(initial_state))
args = [(LightStateRTCState.operator("ref"), "s")]
lamb = await cg.process_lambda(
Lambda(f"s = {initial_state};"),
args,
return_type=cg.void,
)
cg.add(light_var.set_initial_state(lamb))
if (
default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH)
+4 -3
View File
@@ -37,8 +37,9 @@ void LightState::setup() {
auto call = this->make_call();
LightStateRTCState recovered{};
if (this->initial_state_.has_value()) {
recovered = *this->initial_state_;
if (this->initial_state_callback_) {
this->initial_state_callback_(recovered);
this->initial_state_callback_ = nullptr; // One-shot — no longer needed
}
switch (this->restore_mode_) {
case LIGHT_RESTORE_DEFAULT_OFF:
@@ -195,7 +196,7 @@ void LightState::set_flash_transition_length(uint32_t flash_transition_length) {
uint32_t LightState::get_flash_transition_length() const { return this->flash_transition_length_; }
void LightState::set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; }
void LightState::set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
void LightState::set_initial_state(const LightStateRTCState &initial_state) { this->initial_state_ = initial_state; }
void LightState::set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; }
bool LightState::supports_effects() { return !this->effects_.empty(); }
const FixedVector<LightEffect *> &LightState::get_effects() const { return this->effects_; }
void LightState::add_effects(const std::initializer_list<LightEffect *> &effects) {
+6 -4
View File
@@ -188,8 +188,9 @@ class LightState : public EntityBase, public Component {
/// Set the restore mode of this light
void set_restore_mode(LightRestoreMode restore_mode);
/// Set the initial state of this light
void set_initial_state(const LightStateRTCState &initial_state);
/// Set a callback to populate the initial state defaults during setup.
/// The callback is called once, then cleared. Values live in flash as code.
void set_initial_state(void (*callback)(LightStateRTCState &));
/// Return whether the light has any effects that meet the trait requirements.
bool supports_effects();
@@ -342,8 +343,9 @@ class LightState : public EntityBase, public Component {
*/
std::unique_ptr<std::vector<LightTargetStateReachedListener *>> target_state_reached_listeners_;
/// Initial state of the light.
optional<LightStateRTCState> initial_state_{};
/// Callback to populate initial state defaults — called once during setup, then cleared.
/// Values live in flash as function body; no per-instance data storage beyond this pointer.
void (*initial_state_callback_)(LightStateRTCState &){nullptr};
/// Value for storing the index of the currently active effect. 0 if no effect is active
uint32_t active_effect_index_{};
@@ -0,0 +1,39 @@
esphome:
name: light-initial-state-test
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: test_red
type: float
write_action:
- lambda: ""
- platform: template
id: test_green
type: float
write_action:
- lambda: ""
- platform: template
id: test_blue
type: float
write_action:
- lambda: ""
light:
- platform: rgb
name: "Test Light"
id: test_light
red: test_red
green: test_green
blue: test_blue
restore_mode: ALWAYS_OFF
initial_state:
color_mode: RGB
state: true
brightness: 0.75
red: 1.0
green: 0.5
blue: 0.0
@@ -0,0 +1,38 @@
"""Integration test for light initial_state configuration.
Tests that the initial_state values are correctly applied at boot when
no saved preferences exist. The initial_state callback populates defaults
that the restore logic uses as a fallback.
"""
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_light_initial_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that initial_state values are applied at boot."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
light = require_entity(entities, "test_light")
helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(lambda s: None))
await helper.wait_for_initial_states()
state = helper.initial_states[light.key]
# restore_mode: ALWAYS_OFF overrides state to false
assert state.state is False
# But the color values from initial_state should be applied
assert state.brightness == pytest.approx(0.75, abs=0.05)
assert state.red == pytest.approx(1.0, abs=0.01)
assert state.green == pytest.approx(0.5, abs=0.01)
assert state.blue == pytest.approx(0.0, abs=0.01)