From 64388318e837fd5d22f73997e90416781366a395 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:47:25 +1000 Subject: [PATCH] [light] Replace restore_mode with restore_state (#18997) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/light/__init__.py | 166 +++--- esphome/components/light/automation.py | 85 ++- esphome/components/light/effects.py | 3 +- esphome/components/light/light_state.cpp | 69 +-- esphome/components/light/light_state.h | 46 +- esphome/components/light/restore_state.py | 345 +++++++++++ .../components/light/bench_light_call.cpp | 1 - .../light/hoermann_hcp_light_test.cpp | 13 +- tests/components/light/common.yaml | 79 +++ tests/components/light/test.esp32-idf.yaml | 3 +- tests/components/light/test.esp8266-ard.yaml | 3 +- .../components/light/test.nrf52-adafruit.yaml | 3 +- tests/components/light/test.nrf52-mcumgr.yaml | 3 +- tests/components/light/test.rp2040-ard.yaml | 3 +- .../fixtures/light_initial_state.yaml | 46 ++ .../fixtures/light_restore_state.yaml | 35 ++ tests/integration/test_light_initial_state.py | 18 + tests/integration/test_light_restore_state.py | 116 ++++ .../components/light/test_automation.py | 58 ++ .../components/light/test_restore_state.py | 535 ++++++++++++++++++ 20 files changed, 1463 insertions(+), 167 deletions(-) create mode 100644 esphome/components/light/restore_state.py create mode 100644 tests/integration/fixtures/light_restore_state.yaml create mode 100644 tests/integration/test_light_restore_state.py create mode 100644 tests/unit_tests/components/light/test_automation.py create mode 100644 tests/unit_tests/components/light/test_restore_state.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 428d1abf1f..328d01a167 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -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) diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 5ae2660774..c938742dac 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -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 } ) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 3ae15f9ee5..535def0ba0 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -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="_" diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 63c311f5a8..9cbe9639db 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -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(requested); + for (ColorMode mode : traits.get_supported_color_modes()) { + if ((static_cast(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(); + 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(); - // 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(); - 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(); diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 22ce9bf198..4f7d3c9330 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -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> 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 diff --git a/esphome/components/light/restore_state.py b/esphome/components/light/restore_state.py new file mode 100644 index 0000000000..712b489547 --- /dev/null +++ b/esphome/components/light/restore_state.py @@ -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. = ;` 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({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) diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp index c1ef0c425e..cc6871654d 100644 --- a/tests/benchmarks/components/light/bench_light_call.cpp +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -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) --- diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp index ed7e81b279..75b44a3af6 100644 --- a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -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(); diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 5d78c411e7..e1135098e4 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -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 diff --git a/tests/components/light/test.esp32-idf.yaml b/tests/components/light/test.esp32-idf.yaml index 49e49b4318..14300244a7 100644 --- a/tests/components/light/test.esp32-idf.yaml +++ b/tests/components/light/test.esp32-idf.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.esp8266-ard.yaml b/tests/components/light/test.esp8266-ard.yaml index 1eb58eabc4..7f61b1ebd5 100644 --- a/tests/components/light/test.esp8266-ard.yaml +++ b/tests/components/light/test.esp8266-ard.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 16 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.nrf52-adafruit.yaml b/tests/components/light/test.nrf52-adafruit.yaml index 08f5f39810..76224f9c06 100644 --- a/tests/components/light/test.nrf52-adafruit.yaml +++ b/tests/components/light/test.nrf52-adafruit.yaml @@ -20,4 +20,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.nrf52-mcumgr.yaml b/tests/components/light/test.nrf52-mcumgr.yaml index 08f5f39810..76224f9c06 100644 --- a/tests/components/light/test.nrf52-mcumgr.yaml +++ b/tests/components/light/test.nrf52-mcumgr.yaml @@ -20,4 +20,5 @@ output: id: test_ledc_5 pin: 17 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/components/light/test.rp2040-ard.yaml b/tests/components/light/test.rp2040-ard.yaml index 21d5cad774..f6a5ce6295 100644 --- a/tests/components/light/test.rp2040-ard.yaml +++ b/tests/components/light/test.rp2040-ard.yaml @@ -18,4 +18,5 @@ output: id: test_ledc_5 pin: 5 -<<: !include common.yaml +packages: + common: !include common.yaml diff --git a/tests/integration/fixtures/light_initial_state.yaml b/tests/integration/fixtures/light_initial_state.yaml index 052de0a4e5..34b5be9fdc 100644 --- a/tests/integration/fixtures/light_initial_state.yaml +++ b/tests/integration/fixtures/light_initial_state.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% diff --git a/tests/integration/fixtures/light_restore_state.yaml b/tests/integration/fixtures/light_restore_state.yaml new file mode 100644 index 0000000000..ac928a76c6 --- /dev/null +++ b/tests/integration/fixtures/light_restore_state.yaml @@ -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% diff --git a/tests/integration/test_light_initial_state.py b/tests/integration/test_light_initial_state.py index 12ebf7c4a1..12d17bdee6 100644 --- a/tests/integration/test_light_initial_state.py +++ b/tests/integration/test_light_initial_state.py @@ -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) diff --git a/tests/integration/test_light_restore_state.py b/tests/integration/test_light_restore_state.py new file mode 100644 index 0000000000..5772793864 --- /dev/null +++ b/tests/integration/test_light_restore_state.py @@ -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) diff --git a/tests/unit_tests/components/light/test_automation.py b/tests/unit_tests/components/light/test_automation.py new file mode 100644 index 0000000000..6a2a05227d --- /dev/null +++ b/tests/unit_tests/components/light/test_automation.py @@ -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 diff --git a/tests/unit_tests/components/light/test_restore_state.py b/tests/unit_tests/components/light/test_restore_state.py new file mode 100644 index 0000000000..aee3c5ec19 --- /dev/null +++ b/tests/unit_tests/components/light/test_restore_state.py @@ -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(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(0.75);")] + + +def _mask(*modes: str) -> str: + casts = " | ".join(f"static_cast(light::ColorMode::{m})" for m in modes) + return f"static_cast({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)