mirror of
https://github.com/esphome/esphome.git
synced 2026-09-28 15:30:21 +00:00
[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:
co-authored by
Claude Sonnet 5
J. Nick Koston
J. Nick Koston
parent
e83dc25301
commit
64388318e8
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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="_"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,4 +18,5 @@ output:
|
||||
id: test_ledc_5
|
||||
pin: 17
|
||||
|
||||
<<: !include common.yaml
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user