[light] Replace restore_mode with restore_state (#18997)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Clyde Stubbs
2026-09-26 08:47:25 +10:00
committed by GitHub
co-authored by Claude Sonnet 5 J. Nick Koston J. Nick Koston
parent e83dc25301
commit 64388318e8
20 changed files with 1463 additions and 167 deletions
+98 -68
View File
@@ -10,20 +10,13 @@ from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_BLUE,
CONF_BRIGHTNESS,
CONF_COLD_WHITE,
CONF_COLD_WHITE_COLOR_TEMPERATURE,
CONF_COLOR_BRIGHTNESS,
CONF_COLOR_CORRECT,
CONF_COLOR_MODE,
CONF_COLOR_TEMPERATURE,
CONF_DEFAULT_TRANSITION_LENGTH,
CONF_EFFECTS,
CONF_ENTITY_CATEGORY,
CONF_FLASH_TRANSITION_LENGTH,
CONF_GAMMA_CORRECT,
CONF_GREEN,
CONF_ICON,
CONF_ID,
CONF_INITIAL_STATE,
@@ -35,17 +28,14 @@ from esphome.const import (
CONF_ON_TURN_ON,
CONF_OUTPUT_ID,
CONF_POWER_SUPPLY,
CONF_RED,
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
CONF_RGB_ORDER,
CONF_STATE,
CONF_TRIGGER_ID,
CONF_WARM_WHITE,
CONF_WARM_WHITE_COLOR_TEMPERATURE,
CONF_WEB_SERVER,
CONF_WHITE,
)
from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
@@ -64,6 +54,17 @@ from .effects import (
RGB_EFFECTS,
validate_effects,
)
from .restore_state import (
LEGACY_RESTORE_MODES,
RESTORE_STATE_NONE,
RESTORE_STATE_SCHEMA,
_build_state_lambda,
_initial_state_overridden_by_legacy_mode,
_initial_state_statements,
_legacy_cold_boot_statements,
_legacy_restore_statements,
_restore_state_statements,
)
from .types import ( # noqa: F401
AddressableLight,
AddressableLightState,
@@ -273,17 +274,34 @@ def migrate_channel_colors(
def _final_validate(config: ConfigType) -> None:
"""Validate all recorded effect name references against their target lights.
"""Validate every configured light's own resolved config, and all recorded
effect name references against their target lights.
This runs once per light platform instance. If no light platform is configured,
this never runs — but the ID validator will catch the missing light ID separately.
FINAL_VALIDATE_SCHEMA for a platform-based domain like `light:` runs once for
the whole domain, not once per entry -- `config` is the full list of light
platform entries across the file, not a single light's own config.
"""
for light_config in config:
restore_mode = light_config.get(CONF_RESTORE_MODE)
if restore_mode is not None:
legacy = LEGACY_RESTORE_MODES[restore_mode]
if _initial_state_overridden_by_legacy_mode(
legacy, light_config.get(CONF_INITIAL_STATE)
):
_LOGGER.warning(
"[%s] 'initial_state: state' is ignored because 'restore_mode: %s' "
"always sets the light %s at boot; use 'restore_state:' instead for "
"per-field control",
light_config.get(CONF_NAME) or light_config[CONF_ID],
restore_mode,
"ON" if legacy.cold_boot_state else "OFF",
)
data = _get_data()
if not data.effect_refs and not data.effect_cycle_refs:
return
# Drain the lists so we only validate once even though
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
# Drain the lists so each recorded reference is only validated once.
refs = data.effect_refs
data.effect_refs = []
cycle_refs = data.effect_cycle_refs
@@ -328,18 +346,6 @@ def _final_validate(config: ConfigType) -> None:
FINAL_VALIDATE_SCHEMA = _final_validate
LightRestoreMode = light_ns.enum("LightRestoreMode")
RESTORE_MODES = {
"RESTORE_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_DEFAULT_OFF,
"RESTORE_DEFAULT_ON": LightRestoreMode.LIGHT_RESTORE_DEFAULT_ON,
"ALWAYS_OFF": LightRestoreMode.LIGHT_ALWAYS_OFF,
"ALWAYS_ON": LightRestoreMode.LIGHT_ALWAYS_ON,
"RESTORE_INVERTED_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_INVERTED_DEFAULT_OFF,
"RESTORE_INVERTED_DEFAULT_ON": LightRestoreMode.LIGHT_RESTORE_INVERTED_DEFAULT_ON,
"RESTORE_AND_OFF": LightRestoreMode.LIGHT_RESTORE_AND_OFF,
"RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON,
}
# Schema default that also matches the C++ initializer in light_state.h; codegen
# skips the setter when the config equals it.
DEFAULT_FLASH_TRANSITION_LENGTH = "0s"
@@ -354,9 +360,10 @@ LIGHT_SCHEMA = (
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt.MQTTJSONLightComponent
),
cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum(
RESTORE_MODES, upper=True, space="_"
cv.Exclusive(CONF_RESTORE_MODE, "restore"): cv.one_of(
*LEGACY_RESTORE_MODES, upper=True, space="_"
),
cv.Exclusive(CONF_RESTORE_STATE, "restore"): RESTORE_STATE_SCHEMA,
cv.Optional(CONF_ON_TURN_ON): auto.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LightTurnOnTrigger),
@@ -435,6 +442,28 @@ class LightType(enum.IntEnum):
ADDRESSABLE = 3
def _apply_default_restore_mode(
default_restore_mode: str,
) -> Callable[[ConfigType], ConfigType]:
# cv.Exclusive has no default, so apply the default here if neither key is configured.
def validator(config: ConfigType) -> ConfigType:
if CONF_RESTORE_MODE not in config and CONF_RESTORE_STATE not in config:
config[CONF_RESTORE_MODE] = cv.one_of(
*LEGACY_RESTORE_MODES, upper=True, space="_"
)(default_restore_mode)
return config
return validator
_BASE_SCHEMAS: dict[LightType, cv.Schema] = {
LightType.BINARY: BINARY_LIGHT_SCHEMA,
LightType.BRIGHTNESS_ONLY: BRIGHTNESS_ONLY_LIGHT_SCHEMA,
LightType.RGB: RGB_LIGHT_SCHEMA,
LightType.ADDRESSABLE: ADDRESSABLE_LIGHT_SCHEMA,
}
def light_schema(
class_: MockObjClass,
type_: LightType,
@@ -450,25 +479,14 @@ def light_schema(
for key, default, validator in [
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
(CONF_ICON, icon, cv.icon),
(
CONF_RESTORE_MODE,
default_restore_mode,
cv.enum(RESTORE_MODES, upper=True, space="_"),
),
]:
if default is not cv.UNDEFINED:
schema[cv.Optional(key, default=default)] = validator
if type_ == LightType.BINARY:
return BINARY_LIGHT_SCHEMA.extend(schema)
if type_ == LightType.BRIGHTNESS_ONLY:
return BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend(schema)
if type_ == LightType.RGB:
return RGB_LIGHT_SCHEMA.extend(schema)
if type_ == LightType.ADDRESSABLE:
return ADDRESSABLE_LIGHT_SCHEMA.extend(schema)
raise ValueError(f"Invalid light type: {type_}")
result = _BASE_SCHEMAS[type_].extend(schema)
if default_restore_mode is not cv.UNDEFINED:
result.add_extra(_apply_default_restore_mode(default_restore_mode))
return result
def validate_color_temperature_channels(value):
@@ -487,31 +505,43 @@ def validate_color_temperature_channels(value):
@setup_entity("light")
async def setup_light_core_(light_var, config, output_var):
cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE]))
# All 8 legacy restore_mode values, and the restore_state key, are just different
# ways to build the same state callback and save_enabled flag that LightState's
# runtime actually understands.
initial_state_config = config.get(CONF_INITIAL_STATE)
initial_statements = await _initial_state_statements(initial_state_config)
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),
initial_state_config.get(CONF_BRIGHTNESS, 1.0),
initial_state_config.get(CONF_COLOR_BRIGHTNESS, 1.0),
initial_state_config.get(CONF_RED, 1.0),
initial_state_config.get(CONF_GREEN, 1.0),
initial_state_config.get(CONF_BLUE, 1.0),
initial_state_config.get(CONF_WHITE, 1.0),
initial_state_config.get(CONF_COLOR_TEMPERATURE, 1.0),
initial_state_config.get(CONF_COLD_WHITE, 1.0),
initial_state_config.get(CONF_WARM_WHITE, 1.0),
restore_mode = config.get(CONF_RESTORE_MODE)
restore_state_config = config.get(CONF_RESTORE_STATE)
if restore_state_config == RESTORE_STATE_NONE:
# restore_state: none is explicit shorthand for "no restoring at all" --
restore_state_config = None
if restore_mode is not None:
legacy = LEGACY_RESTORE_MODES[restore_mode]
initial_statements.extend(
_legacy_cold_boot_statements(legacy, initial_state_config)
)
args = [(LightStateRTCState.operator("ref"), "s")]
lamb = await cg.process_lambda(
Lambda(f"s = {initial_state};"),
args,
return_type=cg.void,
restore_statements = _legacy_restore_statements(legacy)
save_enabled = legacy.save_enabled
elif restore_state_config is not None:
restore_statements = await _restore_state_statements(
restore_state_config, initial_state_config
)
cg.add(light_var.set_initial_state(lamb))
save_enabled = True
else:
# Neither key configured: no persistence, and no cold-boot forcing either.
restore_statements = []
save_enabled = False
if (
lamb := await _build_state_lambda(
initial_statements, restore_statements, save_enabled
)
) is not None:
cg.add(light_var.set_state_callback(lamb))
if save_enabled: # matches LightState::save_enabled_'s own default of false
cg.add(light_var.set_save_enabled(save_enabled))
if (
default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH)
+74 -11
View File
@@ -1,3 +1,6 @@
from collections.abc import Callable
from typing import Any, NamedTuple
from esphome import automation
import esphome.codegen as cg
from esphome.config import path_context
@@ -28,6 +31,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, EsphomeError, Lambda
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from .types import (
@@ -44,6 +48,28 @@ from .types import (
CONF_INCLUDE_NONE = "include_none"
_STATE_ON_OFF = cv.one_of("ON", "OFF", upper=True)
@schema_extractor("one_of")
def validate_light_state(value: Any) -> Any:
"""Validate a light on/off state.
Documented as 'ON'/'OFF', but accepts all boolean forms for backward compatibility.
"""
if value == SCHEMA_EXTRACT:
return ("ON", "OFF")
try:
return _STATE_ON_OFF(value) == "ON"
except cv.Invalid:
pass
try:
return cv.boolean(value)
except cv.Invalid as err:
raise cv.Invalid(
f"Expected 'ON', 'OFF', or a boolean value, got {value!r}"
) from err
@automation.register_action(
"light.toggle",
@@ -71,19 +97,56 @@ async def light_toggle_to_code(config, action_id, template_arg, args):
return var
class LightStateField(NamedTuple):
"""One field of a light's state: the single source for the schemas and boot-time
codegen that deal with it."""
conf_key: str
# Member of LightStateRTCState holding this field.
member: str
validator: Callable[[Any], Any]
# The color mode providing this field, if it implies one.
color_mode: MockObj | None = None
templatable: bool = True
# In LightStateRTCState member order.
LIGHT_STATE_FIELDS: tuple[LightStateField, ...] = (
LightStateField(CONF_STATE, "state", validate_light_state),
LightStateField(
CONF_COLOR_MODE,
"color_mode",
cv.enum(COLOR_MODES, upper=True, space="_"),
templatable=False,
),
LightStateField(CONF_BRIGHTNESS, "brightness", cv.percentage, ColorMode.BRIGHTNESS),
LightStateField(
CONF_COLOR_BRIGHTNESS, "color_brightness", cv.percentage, ColorMode.RGB
),
LightStateField(CONF_RED, "red", cv.percentage, ColorMode.RGB),
LightStateField(CONF_GREEN, "green", cv.percentage, ColorMode.RGB),
LightStateField(CONF_BLUE, "blue", cv.percentage, ColorMode.RGB),
LightStateField(CONF_WHITE, "white", cv.percentage, ColorMode.WHITE),
LightStateField(
CONF_COLOR_TEMPERATURE,
"color_temp",
cv.color_temperature,
ColorMode.COLOR_TEMPERATURE,
),
LightStateField(
CONF_COLD_WHITE, "cold_white", cv.percentage, ColorMode.COLD_WARM_WHITE
),
LightStateField(
CONF_WARM_WHITE, "warm_white", cv.percentage, ColorMode.COLD_WARM_WHITE
),
)
LIGHT_STATE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_COLOR_MODE): cv.enum(COLOR_MODES, upper=True, space="_"),
cv.Optional(CONF_STATE): cv.templatable(cv.boolean),
cv.Optional(CONF_BRIGHTNESS): cv.templatable(cv.percentage),
cv.Optional(CONF_COLOR_BRIGHTNESS): cv.templatable(cv.percentage),
cv.Optional(CONF_RED): cv.templatable(cv.percentage),
cv.Optional(CONF_GREEN): cv.templatable(cv.percentage),
cv.Optional(CONF_BLUE): cv.templatable(cv.percentage),
cv.Optional(CONF_WHITE): cv.templatable(cv.percentage),
cv.Optional(CONF_COLOR_TEMPERATURE): cv.templatable(cv.color_temperature),
cv.Optional(CONF_COLD_WHITE): cv.templatable(cv.percentage),
cv.Optional(CONF_WARM_WHITE): cv.templatable(cv.percentage),
cv.Optional(field.conf_key): (
cv.templatable(field.validator) if field.templatable else field.validator
)
for field in LIGHT_STATE_FIELDS
}
)
+2 -1
View File
@@ -33,6 +33,7 @@ from esphome.cpp_generator import MockObjClass
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.util import Registry
from .automation import validate_light_state
from .types import (
COLOR_MODES,
AddressableColorWipeEffect,
@@ -281,7 +282,7 @@ async def random_effect_to_code(config, effect_id):
cv.ensure_list(
cv.Schema(
{
cv.Optional(CONF_STATE, default=True): cv.boolean,
cv.Optional(CONF_STATE, default=True): validate_light_state,
cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_MODE): cv.enum(
COLOR_MODES, upper=True, space="_"
+28 -41
View File
@@ -13,6 +13,20 @@ namespace esphome::light {
static const char *const TAG = "light";
// Colour modes are bitmasks of capabilities. A mode the light doesn't support may be a bare set of
// required capabilities (see restore_state.py's colour mode inference): use the first supported
// mode that provides all of them, or leave it unchanged if there is none.
static ColorMode resolve_color_mode(const LightTraits &traits, ColorMode requested) {
if (requested == ColorMode::UNKNOWN || traits.supports_color_mode(requested))
return requested;
auto wanted = static_cast<uint8_t>(requested);
for (ColorMode mode : traits.get_supported_color_modes()) {
if ((static_cast<uint8_t>(mode) & wanted) == wanted)
return mode;
}
return requested;
}
LightState::LightState(LightOutput *output) : output_(output) {}
LightTraits LightState::get_traits() { return this->output_->get_traits(); }
@@ -40,38 +54,14 @@ void LightState::setup() {
auto call = this->make_call();
LightStateRTCState recovered{};
if (this->initial_state_callback_) {
this->initial_state_callback_(recovered);
this->initial_state_callback_ = nullptr; // One-shot — no longer needed
bool restored = false;
if (this->save_enabled_) {
this->rtc_ = this->make_entity_preference<LightStateRTCState>();
restored = this->rtc_.load(&recovered);
}
switch (this->restore_mode_) {
case LIGHT_RESTORE_DEFAULT_OFF:
case LIGHT_RESTORE_DEFAULT_ON:
case LIGHT_RESTORE_INVERTED_DEFAULT_OFF:
case LIGHT_RESTORE_INVERTED_DEFAULT_ON:
this->rtc_ = this->make_entity_preference<LightStateRTCState>();
// Attempt to load from preferences, else fall back to default values
if (!this->rtc_.load(&recovered)) {
recovered.state = (this->restore_mode_ == LIGHT_RESTORE_DEFAULT_ON ||
this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON);
} else if (this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_OFF ||
this->restore_mode_ == LIGHT_RESTORE_INVERTED_DEFAULT_ON) {
// Inverted restore state
recovered.state = !recovered.state;
}
break;
case LIGHT_RESTORE_AND_OFF:
case LIGHT_RESTORE_AND_ON:
this->rtc_ = this->make_entity_preference<LightStateRTCState>();
this->rtc_.load(&recovered);
recovered.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON);
break;
case LIGHT_ALWAYS_OFF:
recovered.state = false;
break;
case LIGHT_ALWAYS_ON:
recovered.state = true;
break;
if (this->state_callback_) {
this->state_callback_(recovered, restored);
this->state_callback_ = nullptr; // One-shot — no longer needed
}
// A light coming up on boot must never end up on-but-invisible: if the resolved restore
@@ -82,7 +72,7 @@ void LightState::setup() {
recovered.brightness = 1.0f;
}
call.set_color_mode_if_supported(recovered.color_mode);
call.set_color_mode_if_supported(resolve_color_mode(traits, recovered.color_mode));
call.set_state(recovered.state);
call.set_brightness_if_supported(recovered.brightness);
call.set_color_brightness_if_supported(recovered.color_brightness);
@@ -428,17 +418,14 @@ void LightState::set_transformer_remote_values_(const LightColorValues &target,
#endif
void LightState::save_remote_values_() {
if (!this->save_enabled_)
return;
LightStateRTCState saved;
saved.color_mode = this->remote_values.get_color_mode();
switch (this->restore_mode_) {
case LIGHT_RESTORE_AND_OFF:
case LIGHT_RESTORE_AND_ON:
saved.state = (this->restore_mode_ == LIGHT_RESTORE_AND_ON);
break;
default:
saved.state = this->remote_values.is_on();
break;
}
// Always the real on/off status (RESTORE_AND_ON/OFF used to persist a hardcoded
// true/false here instead; harmless, since those modes force `state` again on
// every load regardless of what was saved -- see _legacy_restore_statements).
saved.state = this->remote_values.is_on();
saved.brightness = this->remote_values.get_brightness();
saved.color_brightness = this->remote_values.get_color_brightness();
saved.red = this->remote_values.get_red();
+11 -35
View File
@@ -44,33 +44,7 @@ class LightTargetStateReachedListener {
virtual void on_light_target_state_reached() = 0;
};
enum LightRestoreMode : uint8_t {
LIGHT_RESTORE_DEFAULT_OFF,
LIGHT_RESTORE_DEFAULT_ON,
LIGHT_ALWAYS_OFF,
LIGHT_ALWAYS_ON,
LIGHT_RESTORE_INVERTED_DEFAULT_OFF,
LIGHT_RESTORE_INVERTED_DEFAULT_ON,
LIGHT_RESTORE_AND_OFF,
LIGHT_RESTORE_AND_ON,
};
struct LightStateRTCState {
LightStateRTCState(ColorMode color_mode, bool state, float brightness, float color_brightness, float red, float green,
float blue, float white, float color_temp, float cold_white, float warm_white)
: brightness(brightness),
color_brightness(color_brightness),
red(red),
green(green),
blue(blue),
white(white),
color_temp(color_temp),
cold_white(cold_white),
warm_white(warm_white),
effect(0),
color_mode(color_mode),
state(state) {}
LightStateRTCState() = default;
// Group 4-byte aligned members first
float brightness{1.0f};
float color_brightness{1.0f};
@@ -211,12 +185,14 @@ class LightState : public EntityBase, public Component {
float gamma_uncorrect_lut(float value) const { return value; }
#endif // USE_LIGHT_GAMMA_LUT
/// Set the restore mode of this light
void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
/// Set the callback that resolves the boot-time state, called once during setup then
/// cleared. `restored` is true only when a persisted state actually loaded, in which
/// case the state argument already holds the loaded values; otherwise it is freshly
/// default-constructed. Values live in flash as code.
void set_state_callback(void (*callback)(LightStateRTCState &, bool restored)) { this->state_callback_ = callback; }
/// 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 &)) { this->initial_state_callback_ = callback; }
/// Set whether this light persists its state to preferences at all.
void set_save_enabled(bool save_enabled) { this->save_enabled_ = save_enabled; }
/// Return whether the light has any effects that meet the trait requirements.
bool supports_effects() const { return !this->effects_.empty(); }
@@ -382,9 +358,9 @@ class LightState : public EntityBase, public Component {
*/
std::unique_ptr<std::vector<LightTargetStateReachedListener *>> target_state_reached_listeners_;
/// Callback to populate initial state defaults — called once during setup, then cleared.
/// Callback that resolves the boot-time state — 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};
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_{};
@@ -406,12 +382,12 @@ class LightState : public EntityBase, public Component {
bool next_write_{true};
// for effects, true if a transformer (transition) is active.
bool is_transformer_active_{false};
/// Whether this light persists its state to preferences at all.
bool save_enabled_{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};
#endif
/// Restore mode of the light.
LightRestoreMode restore_mode_;
};
} // namespace esphome::light
+345
View File
@@ -0,0 +1,345 @@
"""
Functions and classes to translate the `restore_mode:` and `restore_state:` config keys into
the single runtime state callback that `LightState` actually understands.
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COLOR_MODE, CONF_STATE
from esphome.core import Lambda
from esphome.cpp_generator import call_lambda
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from .automation import LIGHT_STATE_FIELDS, validate_light_state
from .types import ColorMode, LightStateRTCState
RESTORE_STATE_KEEP = "KEEP"
RESTORE_STATE_INVERT = "INVERT"
RESTORE_STATE_INITIAL = "INITIAL"
RESTORE_STATE_ALL = "ALL"
RESTORE_STATE_NONE = "NONE"
@dataclass(frozen=True)
class LegacyRestoreMode:
cold_boot_state: bool
restore_action: bool | str | None # None = no override, "INVERT", or force-to-bool
save_enabled: bool
LEGACY_RESTORE_MODES: dict[str, LegacyRestoreMode] = {
"RESTORE_DEFAULT_OFF": LegacyRestoreMode(False, None, True),
"RESTORE_DEFAULT_ON": LegacyRestoreMode(True, None, True),
"ALWAYS_OFF": LegacyRestoreMode(False, None, False),
"ALWAYS_ON": LegacyRestoreMode(True, None, False),
"RESTORE_INVERTED_DEFAULT_OFF": LegacyRestoreMode(
False, RESTORE_STATE_INVERT, True
),
"RESTORE_INVERTED_DEFAULT_ON": LegacyRestoreMode(True, RESTORE_STATE_INVERT, True),
"RESTORE_AND_OFF": LegacyRestoreMode(False, False, True),
"RESTORE_AND_ON": LegacyRestoreMode(True, True, True),
}
# (config key, LightStateRTCState member) for every field, and for every field but `state`.
_ALL_STATE_FIELDS: tuple[tuple[str, str], ...] = tuple(
(field.conf_key, field.member) for field in LIGHT_STATE_FIELDS
)
_STATE_STRUCT_FIELDS: tuple[tuple[str, str], ...] = tuple(
(conf_key, member)
for conf_key, member in _ALL_STATE_FIELDS
if conf_key != CONF_STATE
)
# Canonical struct-member order, used only to make generated code deterministic --
# these are independent field assignments, so the actual order never affects behavior.
_MEMBER_ORDER: tuple[str, ...] = tuple(member for _, member in _ALL_STATE_FIELDS)
# A pending `s.<member> = <value>;` statement, tagged with the member it writes.
StateStatement = tuple[str, str]
def _inferred_color_mode(initial_state_config: ConfigType | None) -> str | None:
"""A C++ expression for the capabilities needed by the colour fields `initial_state:`
sets, or None if `color_mode:` is given or no colour field is set.
Colour modes are bitmasks of capabilities, so the result is a bare capability set,
not necessarily a real mode; LightState::setup() resolves it to a mode the light
actually supports.
"""
if not initial_state_config or CONF_COLOR_MODE in initial_state_config:
return None
modes = sorted(
{
str(field.color_mode)
for field in LIGHT_STATE_FIELDS
if field.color_mode is not None
and initial_state_config.get(field.conf_key) is not None
}
)
if not modes:
return None
mask = " | ".join(f"static_cast<uint8_t>({mode})" for mode in modes)
return f"static_cast<{ColorMode}>({mask})"
def _partition_state_statements(
initial_statements: list[StateStatement],
restore_statements: list[StateStatement],
save_enabled: bool,
) -> list[str]:
"""Split initial/restore statements into what must run unconditionally versus what
depends on `restored`, and render the resulting lambda body lines.
Fields whose statement is identical in both branches (e.g. RESTORE_AND_ON's
cold-boot and restore-time statements are both "s.state = true;") are hoisted out
of the `restored` branch entirely, so only the fields that actually depend on
`restored` end up inside it -- down to no branch at all when every field overlaps.
A member appearing more than once in the same list keeps only its last statement
(a plain, side-effect-free assignment): matches sequential-execution semantics,
since an earlier write to the same member is always fully overwritten by a later
one in the original code this replaces.
`save_enabled` is false exactly when `restore_statements` is empty and `restored`
is unconditionally false at the call site (nothing is ever loaded), so the
initial-only branch can skip its `if (!restored)` guard entirely.
"""
# dict() over (member, statement) pairs keeps the *last* entry per member.
initial_map = dict(initial_statements)
restore_map = dict(restore_statements)
# A member outside _MEMBER_ORDER would be silently skipped below instead of
# raising -- catch that here so a typo doesn't turn into wrong state on a device.
assert set(initial_map) <= set(_MEMBER_ORDER)
assert set(restore_map) <= set(_MEMBER_ORDER)
common: list[str] = []
only_initial: list[str] = []
only_restore: list[str] = []
for member in _MEMBER_ORDER:
initial_stmt = initial_map.get(member)
restore_stmt = restore_map.get(member)
if initial_stmt is not None and initial_stmt == restore_stmt:
common.append(initial_stmt)
continue
if initial_stmt is not None:
only_initial.append(initial_stmt)
if restore_stmt is not None:
only_restore.append(restore_stmt)
body = common
if only_restore and only_initial:
body += ["if (restored) {", *only_restore, "} else {", *only_initial, "}"]
elif only_restore:
body += ["if (restored) {", *only_restore, "}"]
elif only_initial:
if save_enabled:
body += ["if (!restored) {", *only_initial, "}"]
else:
body += only_initial
return body
async def _build_state_lambda(
initial_statements: list[StateStatement],
restore_statements: list[StateStatement],
save_enabled: bool,
) -> Lambda | None:
"""
Combine the initial and restore statements into a single lambda that applies the
correct values to a `LightStateRTCState &s` depending on whether a persisted state
actually loaded.
"""
if not initial_statements and not restore_statements:
return None
body = _partition_state_statements(
initial_statements, restore_statements, save_enabled
)
args = [(LightStateRTCState.operator("ref"), "s"), (cg.bool_, "restored")]
return await cg.process_lambda(Lambda("\n".join(body)), args, return_type=cg.void)
async def _process_value(value: Any, member: str) -> str:
if isinstance(value, Lambda):
return_type = cg.bool_ if member == CONF_STATE else cg.float_
lamb = await cg.process_lambda(value, [], return_type=return_type)
return call_lambda(lamb)
return cg.safe_exp(value)
async def _initial_state_statements(
initial_state_config: ConfigType | None,
) -> list[StateStatement]:
"""
Create assignments for every field the user set in `initial_state:`, in canonical
struct-member order. A field given as `!lambda` is resolved and called immediately,
the same way `light.control`'s own field lambdas are.
"""
if not initial_state_config:
return []
statements: list[StateStatement] = []
for conf_key, member in _ALL_STATE_FIELDS:
if (value := initial_state_config.get(conf_key)) is None:
continue
statements.append(
(member, f"s.{member} = {await _process_value(value, member)};")
)
if (inferred := _inferred_color_mode(initial_state_config)) is not None:
statements.append(("color_mode", f"s.color_mode = {inferred};"))
return statements
async def _resolve_initial_value(
conf_key: str, member: str, initial_state_config: ConfigType | None
) -> str:
"""
Return the C++ expression to use for a `restore_state:` field whose value is INITIAL
"""
if (
initial_state_config is not None
and (value := initial_state_config.get(conf_key)) is not None
):
return await _process_value(value, member)
if conf_key == CONF_COLOR_MODE and (
inferred := _inferred_color_mode(initial_state_config)
):
return inferred
return f"LightStateRTCState{{}}.{member}"
async def _restore_state_statements(
restore_state_config: ConfigType, initial_state_config: ConfigType | None
) -> list[StateStatement]:
"""
Create a list of statements to apply the user's `restore_state:` config
"""
statements: list[StateStatement] = []
state = restore_state_config[CONF_STATE]
if state == RESTORE_STATE_INVERT:
statements.append(("state", "s.state = !s.state;"))
elif state == RESTORE_STATE_INITIAL:
expr = await _resolve_initial_value(CONF_STATE, "state", initial_state_config)
statements.append(("state", f"s.state = {expr};"))
elif state != RESTORE_STATE_KEEP:
statements.append(("state", f"s.state = {cg.safe_exp(state)};"))
for conf_key, member in _STATE_STRUCT_FIELDS:
value = restore_state_config[conf_key]
if value == RESTORE_STATE_INITIAL:
expr = await _resolve_initial_value(conf_key, member, initial_state_config)
elif value == RESTORE_STATE_KEEP:
continue
else:
expr = cg.safe_exp(value)
statements.append((member, f"s.{member} = {expr};"))
return statements
def _legacy_restore_statements(mode: LegacyRestoreMode) -> list[StateStatement]:
"""
Create a list of statements to apply the legacy restore_mode: behavior.
"""
if mode.restore_action is None:
return []
if mode.restore_action == RESTORE_STATE_INVERT:
return [("state", "s.state = !s.state;")]
return [("state", f"s.state = {str(mode.restore_action).lower()};")]
def _legacy_cold_boot_statements(
mode: LegacyRestoreMode, initial_state_config: ConfigType | None
) -> list[StateStatement]:
"""
Create a list of statements to apply the legacy restore_mode: cold-boot behavior.
"""
existing_state = (
initial_state_config.get(CONF_STATE) if initial_state_config else None
)
if existing_state is None:
if not mode.cold_boot_state:
return [] # already matches LightStateRTCState's own default
elif existing_state == mode.cold_boot_state:
return [] # initial_state: already set exactly this value
return [("state", f"s.state = {str(mode.cold_boot_state).lower()};")]
def _initial_state_overridden_by_legacy_mode(
mode: LegacyRestoreMode, initial_state_config: ConfigType | None
) -> bool:
"""
Is the user-config `initial_state:` value for `state` overridden by the legacy mode?
"""
if initial_state_config is None or CONF_STATE not in initial_state_config:
return False
return initial_state_config[CONF_STATE] != mode.cold_boot_state
def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
"""
Extend a validator to also accept the `KEEP`/`INITIAL` options
"""
@schema_extractor("one_of")
def validate(value: Any) -> Any:
if value == SCHEMA_EXTRACT:
# Editor completion: the sentinels plus the wrapped validator's own values,
# if it is an enum. Numeric validators have none to offer.
try:
inner = tuple(validator(SCHEMA_EXTRACT))
except cv.Invalid:
inner = ()
return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner)
if isinstance(value, str):
upper = value.strip().upper()
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL):
return upper
return validator(value)
return validate
@schema_extractor("one_of")
def _validate_restore_state_state(value: Any) -> str | bool:
if value == SCHEMA_EXTRACT:
return (
RESTORE_STATE_KEEP,
RESTORE_STATE_INVERT,
RESTORE_STATE_INITIAL,
*validate_light_state(SCHEMA_EXTRACT),
)
if isinstance(value, str):
upper = value.strip().upper()
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL):
return upper
return validate_light_state(value)
_RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
{
cv.Optional(field.conf_key, default=RESTORE_STATE_KEEP): (
_validate_restore_state_state
if field.conf_key == CONF_STATE
else _keep_or(field.validator)
)
for field in LIGHT_STATE_FIELDS
}
)
@schema_extractor("schema")
def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str:
"""
The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`.
"""
if value == SCHEMA_EXTRACT:
# The `all`/`none` string shorthands have no representation in the extracted
# docs schema; only the per-field mapping form is walked here.
return _RESTORE_STATE_FIELDS_SCHEMA
if isinstance(value, str):
upper = value.strip().upper()
if upper == RESTORE_STATE_ALL:
value = {}
elif upper == RESTORE_STATE_NONE:
return RESTORE_STATE_NONE
return _RESTORE_STATE_FIELDS_SCHEMA(value)
@@ -39,7 +39,6 @@ static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) {
light.set_default_transition_length(0);
light.set_gamma_correct(2.8f);
light.set_gamma_table(bench_gamma_2_8_fwd);
light.set_restore_mode(light::LIGHT_ALWAYS_OFF);
}
// --- LightCall::perform() with instant RGB color change (Home Assistant API path) ---
@@ -24,14 +24,17 @@ class CountingHoermannHcpLight : public HoermannHcpLight {
int writes{0};
};
// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences.
// Drives the platform against a real LightState. Boots off (no persistence) by default, which
// keeps setup() clear of preferences.
struct LightFixture {
TestableHoermannHcp door;
CountingHoermannHcpLight output{&door};
light::LightState state{&output};
explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) {
this->state.set_restore_mode(restore_mode);
explicit LightFixture(bool boot_on = false) {
if (boot_on) {
this->state.set_state_callback([](light::LightStateRTCState &s, bool /*restored*/) { s.state = true; });
}
this->output.setup();
// setup() queues the restored state for write_state(); the first settle() below delivers it, which is the
// boot ordering tests need to be able to place around the bus controller coming up.
@@ -621,10 +624,10 @@ TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarme
EXPECT_EQ(door.light_toggle_released_at_, 0u);
}
// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be
// Booting the entity on replays a lit state the door has never confirmed, so it has to be
// adopted back to what is known rather than turned into a command.
TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) {
LightFixture fixture{light::LIGHT_ALWAYS_ON};
LightFixture fixture{/*boot_on=*/true};
connect_controller(fixture.door);
fixture.settle();
+79
View File
@@ -173,15 +173,69 @@ number:
id: test_monochromatic_light
brightness: !lambda "return x / 100.0;"
output:
- platform: template
id: light_test_restore_state_keep
type: binary
write_action:
- lambda: ""
- platform: template
id: light_test_restore_state_override
type: binary
write_action:
- lambda: ""
- platform: template
id: light_test_restore_state_none
type: binary
write_action:
- lambda: ""
- platform: template
id: light_test_restore_state_initial
type: binary
write_action:
- lambda: ""
light:
- platform: binary
id: test_binary_light
name: Binary Light
output: light_test_binary
restore_mode: RESTORE_AND_ON
effects:
- strobe:
on_state:
- logger.log: Binary light state changed
# Test restore_state: all (shorthand for {} -- restore everything, no overrides)
- platform: binary
id: test_restore_state_keep
name: Restore State Keep
output: light_test_restore_state_keep
restore_state: all
# Test restore_state: with explicit overrides
- platform: binary
id: test_restore_state_override
name: Restore State Override
output: light_test_restore_state_override
restore_state:
state: INVERT
brightness: 50%
# Test restore_state: none (explicit shorthand for "no restoring at all")
- platform: binary
id: test_restore_state_none
name: Restore State None
output: light_test_restore_state_none
restore_state: none
initial_state:
state: true
# Test restore_state: with INITIAL fields (copy initial_state:'s value on restore)
- platform: binary
id: test_restore_state_initial
name: Restore State Initial
output: light_test_restore_state_initial
initial_state:
state: true
restore_state:
state: INITIAL
brightness: INITIAL
- platform: monochromatic
id: test_monochromatic_light
name: Monochromatic Light
@@ -282,6 +336,31 @@ light:
red: 100%
green: 50%
blue: 50%
# Test initial_state: with every templatable field set via !lambda (color_mode is
# the only field that isn't templatable, so it's the only one left as a literal)
- platform: rgbww
id: test_rgbww_light_initial_state_lambda
name: RGBWW Light Initial State Lambda
red: test_ledc_1
green: test_ledc_2
blue: test_ledc_3
cold_white: test_ledc_4
warm_white: test_ledc_5
cold_white_color_temperature: 153 mireds
warm_white_color_temperature: 500 mireds
color_interlock: true
initial_state:
state: !lambda "return true;"
color_mode: rgb_cold_warm_white
brightness: !lambda "return 0.8;"
color_brightness: !lambda "return 0.9;"
red: !lambda "return 1.0;"
green: !lambda "return 0.5;"
blue: !lambda "return 0.25;"
white: !lambda "return 0.3;"
color_temperature: !lambda "return 300.0;"
cold_white: !lambda "return 0.6;"
warm_white: !lambda "return 0.4;"
# Test StrobeLightEffect with multiple colors
- platform: monochromatic
id: test_strobe_multiple
+2 -1
View File
@@ -18,4 +18,5 @@ output:
id: test_ledc_5
pin: 17
<<: !include common.yaml
packages:
common: !include common.yaml
+2 -1
View File
@@ -18,4 +18,5 @@ output:
id: test_ledc_5
pin: 16
<<: !include common.yaml
packages:
common: !include common.yaml
@@ -20,4 +20,5 @@ output:
id: test_ledc_5
pin: 17
<<: !include common.yaml
packages:
common: !include common.yaml
@@ -20,4 +20,5 @@ output:
id: test_ledc_5
pin: 17
<<: !include common.yaml
packages:
common: !include common.yaml
+2 -1
View File
@@ -18,4 +18,5 @@ output:
id: test_ledc_5
pin: 5
<<: !include common.yaml
packages:
common: !include common.yaml
@@ -6,6 +6,21 @@ logger:
level: DEBUG
output:
- platform: template
id: test_inferred_red
type: float
write_action:
- lambda: ""
- platform: template
id: test_inferred_green
type: float
write_action:
- lambda: ""
- platform: template
id: test_inferred_blue
type: float
write_action:
- lambda: ""
- platform: template
id: test_red
type: float
@@ -26,6 +41,11 @@ output:
type: float
write_action:
- lambda: ""
- platform: template
id: test_no_restore_key_output
type: float
write_action:
- lambda: ""
light:
- platform: rgb
@@ -55,3 +75,29 @@ light:
color_mode: BRIGHTNESS
state: false
brightness: 0%
# With neither restore_mode nor restore_state configured, initial_state: must be
# honored as-is (no implicit forcing to OFF) -- unlike every explicit restore_mode:
# value, which always overrides `state` regardless of initial_state.
- platform: monochromatic
name: "Test No Restore Key Light"
id: test_no_restore_key_light
output: test_no_restore_key_output
initial_state:
color_mode: BRIGHTNESS
state: true
brightness: 60%
# No color_mode: it must be inferred from the colour fields, not left unknown (which
# would drop the colours and leave the light full white).
- platform: rgb
name: "Test Inferred Color Mode Light"
id: test_inferred_color_mode_light
red: test_inferred_red
green: test_inferred_green
blue: test_inferred_blue
initial_state:
state: true
red: 30%
green: 0%
blue: 0%
@@ -0,0 +1,35 @@
esphome:
name: host-light-restore-state-test
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: out_keep
type: float
write_action:
- lambda: ""
- platform: template
id: out_override
type: float
write_action:
- lambda: ""
light:
# restore_state: {} -- restore everything exactly as saved, no overrides
- platform: monochromatic
name: "Test Light Keep"
id: test_light_keep
output: out_keep
restore_state: {}
# restore_state: with explicit overrides -- these must win over whatever was saved
- platform: monochromatic
name: "Test Light Override"
id: test_light_override
output: out_override
restore_state:
state: INVERT
brightness: 100%
@@ -43,3 +43,21 @@ async def test_light_initial_state(
restore_and_on_state = helper.initial_states[restore_and_on_light.key]
assert restore_and_on_state.state is True
assert restore_and_on_state.brightness == pytest.approx(1.0)
# With neither restore_mode nor restore_state configured, initial_state: must
# be honored as-is -- unlike every explicit restore_mode: value, omitting both
# keys entirely must not force the light off.
no_restore_key_light = require_entity(entities, "test_no_restore_key_light")
no_restore_key_state = helper.initial_states[no_restore_key_light.key]
assert no_restore_key_state.state is True
assert no_restore_key_state.brightness == pytest.approx(0.6, abs=0.01)
# color_mode omitted: inferred from the colour fields, so they are applied (the
# light comes up red rather than the default white). RGB values are normalised to
# proportions, so 30% red on its own is pure red.
inferred_light = require_entity(entities, "test_inferred_color_mode_light")
inferred_state = helper.initial_states[inferred_light.key]
assert inferred_state.state is True
assert inferred_state.red == pytest.approx(1.0, abs=0.01)
assert inferred_state.green == pytest.approx(0.0, abs=0.01)
assert inferred_state.blue == pytest.approx(0.0, abs=0.01)
@@ -0,0 +1,116 @@
"""Integration test for the light `restore_state:` key.
Tests that:
1. On first boot (nothing saved yet), lights come up at their hardware default (off) --
restore_state overrides never apply before anything is ever saved.
2. After a state is saved and the device restarts, a `restore_state: {}` light comes
back exactly as saved, while a light with explicit overrides applies those overrides
on top of the loaded state regardless of what was saved.
"""
from __future__ import annotations
import asyncio
import socket
from typing import Any
from aioesphomeapi import LightInfo, LightState
import pytest
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity
from .types import CompileFunction, ConfigWriter
DEVICE_NAME = "host-light-restore-state-test"
@pytest.mark.asyncio
async def test_light_restore_state(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Test restore_state: {} vs restore_state: with overrides, across a restart."""
port, port_socket = reserved_tcp_port
clear_host_prefs(DEVICE_NAME)
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
port_socket.close()
# --- First run: nothing saved yet, both lights must come up off ---
async with (
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
wait_and_connect_api_client(port=port) as client,
):
entities, _ = await client.list_entities_services()
keep_light = require_entity(entities, "test_light_keep", LightInfo)
override_light = require_entity(entities, "test_light_override", LightInfo)
# A single subscription serves both initial-state capture and later
# state-change waits -- a second subscribe_states call would restart the
# device's initial-state iterator and could resolve a wait on replayed data.
loop = asyncio.get_running_loop()
state_futures: dict[int, asyncio.Future[Any]] = {}
def on_state(state: Any) -> None:
if state.key in state_futures and not state_futures[state.key].done():
state_futures[state.key].set_result(state)
helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(on_state))
await helper.wait_for_initial_states()
keep_initial = helper.initial_states[keep_light.key]
assert isinstance(keep_initial, LightState)
assert keep_initial.state is False
override_initial = helper.initial_states[override_light.key]
assert isinstance(override_initial, LightState)
assert override_initial.state is False
# Set both lights to a known, saved state
async def wait_for_state(key: int, timeout: float = 2.0) -> Any:
state_futures[key] = loop.create_future()
try:
return await asyncio.wait_for(state_futures[key], timeout)
finally:
state_futures.pop(key, None)
client.light_command(key=keep_light.key, state=True, brightness=0.64)
await wait_for_state(keep_light.key)
client.light_command(key=override_light.key, state=True, brightness=0.3)
await wait_for_state(override_light.key)
# --- Second run: same binary, same prefs file ---
async with (
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
wait_and_connect_api_client(port=port) as client,
):
entities, _ = await client.list_entities_services()
keep_light = require_entity(entities, "test_light_keep", LightInfo)
override_light = require_entity(entities, "test_light_override", LightInfo)
helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(lambda s: None))
await helper.wait_for_initial_states()
# restore_state: {} -- comes back exactly as saved
keep_state = helper.initial_states[keep_light.key]
assert isinstance(keep_state, LightState)
assert keep_state.state is True
assert keep_state.brightness == pytest.approx(0.64, abs=0.01)
# restore_state: with overrides -- state inverted, brightness forced to 100%
# regardless of what was actually saved (0.3)
override_state = helper.initial_states[override_light.key]
assert isinstance(override_state, LightState)
assert override_state.state is False
assert override_state.brightness == pytest.approx(1.0, abs=0.01)
clear_host_prefs(DEVICE_NAME)
@@ -0,0 +1,58 @@
"""Tests for validate_light_state -- a light on/off value that prioritizes ON/OFF
string forms over generic boolean forms, while still accepting the latter."""
import pytest
import yaml
from esphome.components.light.automation import validate_light_state
import esphome.config_validation as cv
from esphome.schema_extractors import SCHEMA_EXTRACT
@pytest.mark.parametrize(
("value", "expected"),
[
("ON", True),
("on", True),
("On", True),
("OFF", False),
("off", False),
("Off", False),
(True, True),
(False, False),
("true", True),
("false", False),
("yes", True),
("no", False),
],
)
def test_validate_light_state_accepts_on_off_and_booleans(
value: str | bool, expected: bool
) -> None:
assert validate_light_state(value) is expected
def test_validate_light_state_rejects_invalid_string() -> None:
# The error must mention both accepted forms (ON/OFF and boolean), not just
# whichever validator happened to run last.
with pytest.raises(cv.Invalid, match="ON.*OFF.*boolean"):
validate_light_state("maybe")
def test_validate_light_state_schema_extractor_reports_on_off() -> None:
assert validate_light_state(SCHEMA_EXTRACT) == ("ON", "OFF")
def test_validate_light_state_quoted_on_off_survive_yaml_parsing() -> None:
"""The default PyYAML resolver treats bareword on/off/yes/no as booleans, so a real
YAML config must quote 'ON'/'OFF' for validate_light_state's string-matching branch
to ever see a string at all -- an unquoted `state: on` already arrives as a native
bool. Both forms must still validate to the same result.
"""
quoted = yaml.safe_load('state: "ON"')["state"]
assert quoted == "ON"
assert validate_light_state(quoted) is True
unquoted = yaml.safe_load("state: on")["state"]
assert unquoted is True # PyYAML already converted it before validation runs
assert validate_light_state(unquoted) is True
@@ -0,0 +1,535 @@
"""Tests for the restore_mode/restore_state -> runtime lambda translation layer."""
import logging
import pytest
import esphome.codegen as cg
from esphome.components.light import (
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
LIGHT_SCHEMA,
LightType,
_final_validate,
light_schema,
)
from esphome.components.light.restore_state import (
_RESTORE_STATE_FIELDS_SCHEMA,
LEGACY_RESTORE_MODES,
RESTORE_STATE_INITIAL,
RESTORE_STATE_INVERT,
RESTORE_STATE_KEEP,
RESTORE_STATE_NONE,
RESTORE_STATE_SCHEMA,
StateStatement,
_initial_state_overridden_by_legacy_mode,
_initial_state_statements,
_legacy_cold_boot_statements,
_partition_state_statements,
_restore_state_statements,
_validate_restore_state_state,
)
from esphome.components.light.types import ColorMode
import esphome.config_validation as cv
from esphome.const import CONF_STATE
from esphome.core import Lambda
from esphome.schema_extractors import SCHEMA_EXTRACT
# (mode name, expected cold_boot_state, expected restore_action, expected save_enabled)
LEGACY_MODE_TABLE = [
("RESTORE_DEFAULT_OFF", False, None, True),
("RESTORE_DEFAULT_ON", True, None, True),
("ALWAYS_OFF", False, None, False),
("ALWAYS_ON", True, None, False),
("RESTORE_INVERTED_DEFAULT_OFF", False, "INVERT", True),
("RESTORE_INVERTED_DEFAULT_ON", True, "INVERT", True),
("RESTORE_AND_OFF", False, False, True),
("RESTORE_AND_ON", True, True, True),
]
@pytest.mark.parametrize(
("mode", "cold_boot_state", "restore_action", "save_enabled"), LEGACY_MODE_TABLE
)
def test_legacy_restore_mode_translation(
mode: str, cold_boot_state: bool, restore_action, save_enabled: bool
) -> None:
legacy = LEGACY_RESTORE_MODES[mode]
assert legacy.cold_boot_state is cold_boot_state
assert legacy.restore_action == restore_action
assert legacy.save_enabled is save_enabled
def test_all_eight_legacy_modes_present() -> None:
assert set(LEGACY_RESTORE_MODES) == {mode for mode, *_ in LEGACY_MODE_TABLE}
def test_restore_mode_and_restore_state_are_exclusive() -> None:
with pytest.raises(cv.Invalid, match="restore"):
LIGHT_SCHEMA(
{
"name": "test",
CONF_RESTORE_MODE: "ALWAYS_ON",
CONF_RESTORE_STATE: {},
}
)
def test_neither_restore_key_required_or_defaulted() -> None:
config = LIGHT_SCHEMA({"name": "test"})
assert CONF_RESTORE_MODE not in config
assert CONF_RESTORE_STATE not in config
def test_restore_state_empty_config_keeps_everything() -> None:
config = RESTORE_STATE_SCHEMA({})
assert all(value == RESTORE_STATE_KEEP for value in config.values())
@pytest.mark.parametrize("value", ["all", "All", "ALL"])
def test_restore_state_all_shorthand_is_case_insensitive(value: str) -> None:
assert RESTORE_STATE_SCHEMA(value) == RESTORE_STATE_SCHEMA({})
def test_restore_state_rejects_other_strings() -> None:
with pytest.raises(cv.Invalid):
RESTORE_STATE_SCHEMA("everything")
_DummyLight = cg.esphome_ns.class_("DummyLight")
def test_default_restore_mode_applies_when_neither_key_given() -> None:
schema = light_schema(
_DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON"
)
config = schema({"id": "light1"})
assert config[CONF_RESTORE_MODE] == "RESTORE_DEFAULT_ON"
assert CONF_RESTORE_STATE not in config
def test_default_restore_mode_is_dropped_when_restore_state_given() -> None:
schema = light_schema(
_DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON"
)
config = schema({"id": "light1", "restore_state": {"state": "INVERT"}})
assert CONF_RESTORE_MODE not in config
assert config[CONF_RESTORE_STATE][CONF_STATE] == "INVERT"
def test_default_restore_mode_still_exclusive_with_explicit_restore_state() -> None:
schema = light_schema(
_DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON"
)
with pytest.raises(cv.Invalid, match="restore"):
schema(
{
"id": "light1",
CONF_RESTORE_MODE: "ALWAYS_ON",
CONF_RESTORE_STATE: {},
}
)
def test_default_restore_mode_result_still_extendable() -> None:
# light_schema() must keep returning a real cv.Schema (not e.g. cv.All) even when
# default_restore_mode is given, since every in-tree light platform chains
# .extend() on its result.
schema = light_schema(
_DummyLight, LightType.BINARY, default_restore_mode="RESTORE_DEFAULT_ON"
)
extended = schema.extend({})
config = extended({"id": "light1"})
assert config[CONF_RESTORE_MODE] == "RESTORE_DEFAULT_ON"
@pytest.mark.parametrize("value", ["none", "None", "NONE"])
def test_restore_state_none_shorthand_is_case_insensitive(value: str) -> None:
assert RESTORE_STATE_SCHEMA(value) == RESTORE_STATE_NONE
def test_restore_state_none_is_still_exclusive_with_restore_mode() -> None:
# cv.Exclusive checks which keys are present, regardless of their resolved
# value, so restore_state: none must still conflict with restore_mode:.
with pytest.raises(cv.Invalid, match="restore"):
LIGHT_SCHEMA(
{
"name": "test",
CONF_RESTORE_MODE: "ALWAYS_ON",
CONF_RESTORE_STATE: "none",
}
)
def test_restore_state_explicit_overrides_leave_others_keep() -> None:
config = RESTORE_STATE_SCHEMA({"state": "invert", "brightness": "100%"})
assert config["state"] == "INVERT"
assert config["brightness"] == pytest.approx(1.0)
assert config["color_mode"] == RESTORE_STATE_KEEP
assert config["red"] == RESTORE_STATE_KEEP
@pytest.mark.parametrize("value", ["keep", "Keep", "KEEP"])
def test_restore_state_state_accepts_keep_case_insensitively(value: str) -> None:
assert RESTORE_STATE_SCHEMA({"state": value})["state"] == RESTORE_STATE_KEEP
@pytest.mark.parametrize("value", ["invert", "Invert", "INVERT"])
def test_restore_state_state_accepts_invert_case_insensitively(value: str) -> None:
assert RESTORE_STATE_SCHEMA({"state": value})["state"] == "INVERT"
@pytest.mark.parametrize("value", ["initial", "Initial", "INITIAL"])
def test_restore_state_state_accepts_initial_case_insensitively(value: str) -> None:
assert RESTORE_STATE_SCHEMA({"state": value})["state"] == "INITIAL"
@pytest.mark.parametrize("value", ["initial", "Initial", "INITIAL"])
def test_restore_state_other_fields_accept_initial_case_insensitively(
value: str,
) -> None:
assert RESTORE_STATE_SCHEMA({"brightness": value})["brightness"] == "INITIAL"
@pytest.mark.parametrize(
("value", "expected"), [("ON", True), ("OFF", False), (True, True), (False, False)]
)
def test_restore_state_state_prioritizes_on_off(
value: str | bool, expected: bool
) -> None:
# A quoted "ON"/"OFF" string, distinct from KEEP/INVERT, still resolves via
# validate_light_state -- matching initial_state:'s own state field.
assert RESTORE_STATE_SCHEMA({"state": value})["state"] is expected
def test_validate_restore_state_state_schema_extract_reports_all_options() -> None:
# Regression test: SCHEMA_EXTRACT is an object() sentinel, not a str, so a naive
# isinstance(value, str) check falls through to validate_light_state() and silently
# drops KEEP/INVERT/INITIAL from the extracted docs schema.
assert _validate_restore_state_state(SCHEMA_EXTRACT) == (
RESTORE_STATE_KEEP,
RESTORE_STATE_INVERT,
RESTORE_STATE_INITIAL,
"ON",
"OFF",
)
def test_restore_state_schema_extract_returns_fields_schema() -> None:
# The `all`/`none` shorthands aren't representable here; extraction only walks
# the per-field mapping form, so it must resolve to the real fields schema
# rather than falling through to the untyped/unknown bucket.
assert RESTORE_STATE_SCHEMA(SCHEMA_EXTRACT) is _RESTORE_STATE_FIELDS_SCHEMA
@pytest.mark.parametrize(
("field", "value"),
[
("state", Lambda("return true;")),
("brightness", Lambda("return 1.0;")),
("color_mode", Lambda("return light::ColorMode::ON_OFF;")),
],
)
def test_restore_state_fields_reject_lambda(field: str, value: Lambda) -> None:
with pytest.raises(cv.Invalid):
RESTORE_STATE_SCHEMA({field: value})
@pytest.mark.parametrize(
("mode", "initial_state_config", "expected"),
[
# No initial_state:, cold_boot_state False -- already matches
# LightStateRTCState's own `state{false}` default, nothing to emit.
("ALWAYS_OFF", None, []),
("ALWAYS_OFF", {}, []),
# No initial_state:, cold_boot_state True -- differs from the default.
("ALWAYS_ON", None, [("state", "s.state = true;")]),
# initial_state: already set exactly the cold-boot value -- redundant.
("ALWAYS_OFF", {CONF_STATE: False}, []),
("RESTORE_AND_ON", {CONF_STATE: True}, []),
# initial_state: set a different value -- must be overridden.
("ALWAYS_OFF", {CONF_STATE: True}, [("state", "s.state = false;")]),
("RESTORE_AND_ON", {CONF_STATE: False}, [("state", "s.state = true;")]),
],
)
def test_legacy_cold_boot_statements_skips_redundant_defaults(
mode: str, initial_state_config: dict | None, expected: list[StateStatement]
) -> None:
legacy = LEGACY_RESTORE_MODES[mode]
assert _legacy_cold_boot_statements(legacy, initial_state_config) == expected
def test_partition_no_overlap_produces_full_if_else() -> None:
body = _partition_state_statements(
[("brightness", "s.brightness = 1.0f;")],
[("state", "s.state = false;")],
True,
)
assert body == [
"if (restored) {",
"s.state = false;",
"} else {",
"s.brightness = 1.0f;",
"}",
]
def test_partition_full_overlap_drops_branch_entirely() -> None:
# Both branches want exactly the same thing (e.g. RESTORE_AND_ON): no `if` at all.
body = _partition_state_statements(
[("state", "s.state = true;")],
[("state", "s.state = true;")],
True,
)
assert body == ["s.state = true;"]
def test_partition_partial_overlap_hoists_shared_field() -> None:
# `state` matches in both branches and is hoisted out; `red`/`color_mode` differ
# per branch and stay inside a single-sided `if` each.
body = _partition_state_statements(
[
("red", "s.red = 0.5f;"),
("state", "s.state = true;"),
],
[
("state", "s.state = true;"),
("color_mode", "s.color_mode = light::ColorMode::ON_OFF;"),
],
True,
)
assert body == [
"s.state = true;",
"if (restored) {",
"s.color_mode = light::ColorMode::ON_OFF;",
"} else {",
"s.red = 0.5f;",
"}",
]
def test_partition_duplicate_member_in_one_list_keeps_last_write() -> None:
# initial_statements can legitimately contain two writes to `state`: the user's own
# initial_state: value, followed by a legacy mode's cold-boot override. The earlier
# one is dead code (immediately overwritten) and must not survive partitioning.
body = _partition_state_statements(
[
("state", "s.state = true;"),
("state", "s.state = false;"),
],
[("state", "s.state = false;")],
True,
)
assert body == ["s.state = false;"]
def test_partition_initial_only_without_save_skips_guard() -> None:
# save_enabled False means restored is unconditionally false at the call site
# (e.g. ALWAYS_OFF, restore_state: none, or neither key configured), so guarding
# the initial-only statements behind `if (!restored)` would only waste flash.
body = _partition_state_statements(
[("brightness", "s.brightness = 1.0f;")],
[],
False,
)
assert body == ["s.brightness = 1.0f;"]
@pytest.mark.parametrize(
("mode", "initial_state_config", "expected"),
[
# No initial_state: at all -- nothing to override.
("ALWAYS_OFF", None, False),
("ALWAYS_ON", None, False),
# initial_state: set, but doesn't include state -- nothing to override.
("ALWAYS_OFF", {}, False),
# initial_state: state already matches the mode's cold-boot value -- no-op.
("ALWAYS_OFF", {CONF_STATE: False}, False),
("RESTORE_AND_ON", {CONF_STATE: True}, False),
# initial_state: state set to something the mode's cold-boot force overrides.
("ALWAYS_OFF", {CONF_STATE: True}, True),
("RESTORE_AND_ON", {CONF_STATE: False}, True),
],
)
def test_initial_state_overridden_by_legacy_mode(
mode: str, initial_state_config: dict | None, expected: bool
) -> None:
legacy = LEGACY_RESTORE_MODES[mode]
assert (
_initial_state_overridden_by_legacy_mode(legacy, initial_state_config)
== expected
)
def test_final_validate_warns_when_restore_mode_overrides_initial_state(
caplog: pytest.LogCaptureFixture,
) -> None:
# Regression test: this warning used to fire from setup_light_core_() during
# codegen; it now runs as part of FINAL_VALIDATE_SCHEMA instead, so it also
# surfaces on a plain `esphome config`, not just a full compile.
#
# FINAL_VALIDATE_SCHEMA for the `light:` domain runs once for the whole list
# of configured lights, not once per light -- pass a one-element list, matching
# the real call shape, not the single light's own config dict.
config = [
LIGHT_SCHEMA(
{
"name": "test",
CONF_RESTORE_MODE: "ALWAYS_OFF",
"initial_state": {"state": True},
}
)
]
with caplog.at_level(logging.WARNING):
_final_validate(config)
assert "'initial_state: state' is ignored" in caplog.text
assert "restore_mode: ALWAYS_OFF" in caplog.text
def test_final_validate_does_not_warn_without_conflict(
caplog: pytest.LogCaptureFixture,
) -> None:
config = [
LIGHT_SCHEMA(
{
"name": "test",
CONF_RESTORE_MODE: "ALWAYS_OFF",
"initial_state": {"state": False},
}
)
]
with caplog.at_level(logging.WARNING):
_final_validate(config)
assert caplog.text == ""
@pytest.mark.asyncio
async def test_restore_state_initial_state_field_copies_initial_state_value() -> None:
restore_state_config = RESTORE_STATE_SCHEMA({"state": "initial"})
statements = await _restore_state_statements(
restore_state_config, {CONF_STATE: True}
)
assert statements == [("state", "s.state = true;")]
@pytest.mark.asyncio
async def test_restore_state_initial_other_field_copies_initial_state_value() -> None:
restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"})
initial_state_config = {"brightness": 0.5}
statements = await _restore_state_statements(
restore_state_config, initial_state_config
)
assert statements == [("brightness", "s.brightness = 0.5f;")]
@pytest.mark.asyncio
async def test_restore_state_initial_falls_back_to_struct_default() -> None:
# No initial_state: at all -- INITIAL resolves to a read of LightStateRTCState's
# own member-initializer default, straight from the struct.
restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"})
statements = await _restore_state_statements(restore_state_config, None)
assert statements == [
("brightness", "s.brightness = LightStateRTCState{}.brightness;")
]
@pytest.mark.asyncio
async def test_restore_state_initial_falls_back_when_initial_state_omits_field() -> (
None
):
restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"})
initial_state_config = {"state": True} # doesn't set brightness
statements = await _restore_state_statements(
restore_state_config, initial_state_config
)
assert statements == [
("brightness", "s.brightness = LightStateRTCState{}.brightness;")
]
@pytest.mark.asyncio
async def test_restore_state_initial_resolves_templated_initial_state() -> None:
# initial_state: gave `state` as a lambda -- INITIAL must call it (and cast its
# result), not just copy a literal value.
restore_state_config = RESTORE_STATE_SCHEMA({"state": "initial"})
initial_state_config = {CONF_STATE: Lambda("return true;")}
statements = await _restore_state_statements(
restore_state_config, initial_state_config
)
assert statements == [("state", "s.state = static_cast<bool>(true);")]
@pytest.mark.asyncio
async def test_restore_state_initial_resolves_templated_non_boolean_field() -> None:
# Same as above, but for a float-valued field -- the lambda's return type must
# be float, not bool.
restore_state_config = RESTORE_STATE_SCHEMA({"brightness": "initial"})
initial_state_config = {"brightness": Lambda("return 0.75;")}
statements = await _restore_state_statements(
restore_state_config, initial_state_config
)
assert statements == [("brightness", "s.brightness = static_cast<float>(0.75);")]
def _mask(*modes: str) -> str:
casts = " | ".join(f"static_cast<uint8_t>(light::ColorMode::{m})" for m in modes)
return f"static_cast<light::ColorMode>({casts})"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("initial_state_config", "expected"),
[
({CONF_STATE: True}, None),
({"red": 0.3}, _mask("RGB")),
({"red": 0.3, "green": 0.0, "color_brightness": 0.5}, _mask("RGB")),
({"brightness": 0.5}, _mask("BRIGHTNESS")),
({"white": 0.5}, _mask("WHITE")),
({"cold_white": 0.5, "warm_white": 0.5}, _mask("COLD_WARM_WHITE")),
(
{"red": 0.3, "white": 0.5},
_mask("RGB", "WHITE"),
),
({"red": Lambda("return 0.3;")}, _mask("RGB")),
],
)
async def test_initial_state_infers_color_mode_from_colour_fields(
initial_state_config: dict, expected: str | None
) -> None:
statements = dict(await _initial_state_statements(initial_state_config))
if expected is None:
assert "color_mode" not in statements
else:
assert statements["color_mode"] == f"s.color_mode = {expected};"
@pytest.mark.asyncio
async def test_initial_state_explicit_color_mode_is_not_inferred() -> None:
statements = dict(
await _initial_state_statements({"color_mode": ColorMode.RGB, "red": 0.3})
)
assert statements["color_mode"] == "s.color_mode = light::ColorMode::RGB;"
@pytest.mark.asyncio
async def test_restore_state_initial_color_mode_uses_inferred_mode() -> None:
restore_state_config = RESTORE_STATE_SCHEMA({"color_mode": "initial"})
statements = await _restore_state_statements(restore_state_config, {"red": 0.3})
assert statements == [("color_mode", f"s.color_mode = {_mask('RGB')};")]
def test_every_restore_state_field_offers_completion() -> None:
# Regression test: each field's validator must answer SCHEMA_EXTRACT (instead of
# raising), or the editor gets no completion for it.
for key, validator in _RESTORE_STATE_FIELDS_SCHEMA.schema.items():
values = validator(SCHEMA_EXTRACT)
assert RESTORE_STATE_KEEP in values, key
assert RESTORE_STATE_INITIAL in values, key
def test_restore_state_color_mode_completion_includes_color_modes() -> None:
validator = _RESTORE_STATE_FIELDS_SCHEMA.schema[
next(k for k in _RESTORE_STATE_FIELDS_SCHEMA.schema if k == "color_mode")
]
values = validator(SCHEMA_EXTRACT)
assert {"KEEP", "INITIAL", "RGB", "COLD_WARM_WHITE"} <= set(values)