mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 22:06:12 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d607cdb5ff | ||
|
|
a6a6e93aa9 | ||
|
|
5bcc3f42e9 | ||
|
|
d6aa5ba14c |
@@ -446,6 +446,8 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
|
||||
to still drive a state when the restore mode is disabled.
|
||||
|
||||
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
|
||||
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
|
||||
|
||||
@@ -2213,6 +2213,22 @@ def idf_version() -> cv.Version:
|
||||
return CORE.data[KEY_ESP32][KEY_IDF_VERSION]
|
||||
|
||||
|
||||
def require_idf_version(
|
||||
minimum: cv.Version, feature: str = "This feature"
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Validator for an option that needs the underlying ESP-IDF at ``minimum`` or newer."""
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
cv.only_on_esp32(value)
|
||||
if (version := idf_version()) < minimum:
|
||||
raise cv.Invalid(
|
||||
f"{feature} requires ESP-IDF {minimum} or newer, got {version}"
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def require_fatfs() -> None:
|
||||
"""Mark that FATFS support is required by a component.
|
||||
|
||||
|
||||
@@ -285,12 +285,8 @@ void LD6002BComponent::setup() {
|
||||
#ifdef USE_SWITCH
|
||||
if (this->target_display_switch_ != nullptr) {
|
||||
target_display_controlled = true;
|
||||
// Nothing reports this switch back, so its restored state is the only state
|
||||
// there is. Restoring through the switch keeps its inversion in the path:
|
||||
// the restored value is logical, and driving the switch is what turns it
|
||||
// into the raw command, the published state and the stream flag.
|
||||
const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true);
|
||||
this->target_display_switch_->control(state);
|
||||
// Nothing reports this switch back, so its restored state is the only state there is.
|
||||
this->target_display_switch_->restore(true);
|
||||
}
|
||||
#endif
|
||||
if (!target_display_controlled) {
|
||||
@@ -307,10 +303,7 @@ void LD6002BComponent::setup() {
|
||||
#ifdef USE_SWITCH
|
||||
if (this->point_cloud_switch_ != nullptr) {
|
||||
point_cloud_controlled = true;
|
||||
// The switch owns the stream, so it is also what applies the restored state:
|
||||
// driving it rather than the module keeps the entity's inversion in the path.
|
||||
const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false);
|
||||
this->point_cloud_switch_->control(state);
|
||||
this->point_cloud_switch_->restore(false);
|
||||
}
|
||||
#endif
|
||||
if (!point_cloud_controlled) {
|
||||
@@ -349,11 +342,9 @@ void LD6002BComponent::setup() {
|
||||
#ifdef USE_SWITCH
|
||||
bool want_low_power = this->low_power_switch_ != nullptr;
|
||||
if (want_low_power) {
|
||||
// The module reports this one back, so the query below confirms what it took.
|
||||
// Driving the switch applies its inversion; it also marks the restored value
|
||||
// as reported, so the work mode fallback runs on that until the query lands.
|
||||
const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false);
|
||||
this->low_power_switch_->control(state);
|
||||
// The query below confirms what the module took; until it lands, the work mode
|
||||
// fallback runs on the restored value.
|
||||
this->low_power_switch_->restore(false);
|
||||
}
|
||||
#else
|
||||
bool want_low_power = false;
|
||||
|
||||
@@ -37,6 +37,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
RESTORE_STATE_NONE,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
@@ -56,7 +57,6 @@ from .effects import (
|
||||
)
|
||||
from .restore_state import (
|
||||
LEGACY_RESTORE_MODES,
|
||||
RESTORE_STATE_NONE,
|
||||
RESTORE_STATE_SCHEMA,
|
||||
_build_state_lambda,
|
||||
_initial_state_overridden_by_legacy_mode,
|
||||
|
||||
@@ -11,6 +11,14 @@ 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.core.entity_helpers import (
|
||||
RESTORE_STATE_INITIAL,
|
||||
RESTORE_STATE_INVERT,
|
||||
RESTORE_STATE_KEEP,
|
||||
RESTORE_STATE_NONE,
|
||||
restore_state_keyword,
|
||||
restore_state_schema,
|
||||
)
|
||||
from esphome.cpp_generator import call_lambda
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
@@ -18,12 +26,6 @@ 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:
|
||||
@@ -290,10 +292,12 @@ def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
||||
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
|
||||
if (
|
||||
keyword := restore_state_keyword(
|
||||
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL)
|
||||
)
|
||||
) is not None:
|
||||
return keyword
|
||||
return validator(value)
|
||||
|
||||
return validate
|
||||
@@ -308,10 +312,12 @@ def _validate_restore_state_state(value: Any) -> str | bool:
|
||||
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
|
||||
if (
|
||||
keyword := restore_state_keyword(
|
||||
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL)
|
||||
)
|
||||
) is not None:
|
||||
return keyword
|
||||
return validate_light_state(value)
|
||||
|
||||
|
||||
@@ -327,19 +333,7 @@ _RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
# A mapping of per-field overrides, `all`, or `none`, which stays the keyword for to_code
|
||||
RESTORE_STATE_SCHEMA = restore_state_schema(
|
||||
_RESTORE_STATE_FIELDS_SCHEMA, RESTORE_STATE_NONE
|
||||
)
|
||||
|
||||
@@ -12,13 +12,7 @@ ESPHOME_LOG_TAG(TAG, "modbus_controller.switch");
|
||||
// Maximum bytes to log in verbose hex output
|
||||
static constexpr size_t MODBUS_SWITCH_MAX_LOG_BYTES = 64;
|
||||
|
||||
void ModbusSwitch::setup() {
|
||||
optional<bool> initial_state = Switch::get_initial_state_with_restore_mode();
|
||||
if (initial_state.has_value()) {
|
||||
// if it has a value, restore_mode is not "DISABLED", therefore act on the switch:
|
||||
this->control(initial_state.value());
|
||||
}
|
||||
}
|
||||
void ModbusSwitch::setup() { this->restore(); }
|
||||
void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); }
|
||||
|
||||
bool ModbusSwitch::assumed_state() { return this->assumed_state_; }
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace esphome::output {
|
||||
ESPHOME_LOG_TAG(TAG, "output.switch");
|
||||
|
||||
void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); }
|
||||
void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); }
|
||||
void OutputSwitch::setup() { this->restore(false); }
|
||||
void OutputSwitch::write_state(bool state) {
|
||||
this->output_->set_state(state);
|
||||
this->publish_state(state);
|
||||
|
||||
@@ -13,7 +13,7 @@ ESPHOME_LOG_TAG(TAG, "sendspin.switch");
|
||||
|
||||
void SendspinEnabledSwitch::setup() {
|
||||
// The hub waits for this request, so a restore mode without a state still has to answer.
|
||||
this->control(this->get_initial_state_with_restore_mode().value_or(true));
|
||||
this->restore(true);
|
||||
}
|
||||
|
||||
void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); }
|
||||
@@ -28,7 +28,7 @@ void SendspinEnabledSwitch::write_state(bool state) {
|
||||
|
||||
void SendspinUnpairedAccessSwitch::setup() {
|
||||
// The hub holds the client's first start for this, so a restore mode without a state still has to answer.
|
||||
this->control(this->get_initial_state_with_restore_mode().value_or(true));
|
||||
this->restore(true);
|
||||
}
|
||||
|
||||
void SendspinUnpairedAccessSwitch::dump_config() { LOG_SWITCH("", "Sendspin Unpaired Access Switch", this); }
|
||||
|
||||
@@ -17,8 +17,8 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32S2,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
idf_version,
|
||||
only_on_variant,
|
||||
require_idf_version,
|
||||
)
|
||||
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
@@ -143,19 +143,12 @@ TYPE_CLASS = {
|
||||
}
|
||||
|
||||
|
||||
def _require_psram_dma_idf(value: Any) -> Any:
|
||||
if idf_version() < cv.Version(5, 5, 3):
|
||||
raise cv.Invalid("psram_dma requires ESP-IDF 5.5.3 or newer")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_psram_dma(value: Any) -> bool:
|
||||
value = cv.boolean(value)
|
||||
if not value:
|
||||
return value
|
||||
return cv.All(
|
||||
cv.only_on_esp32,
|
||||
_require_psram_dma_idf,
|
||||
require_idf_version(cv.Version(5, 5, 3), "psram_dma"),
|
||||
only_on_variant(
|
||||
supported=[
|
||||
VARIANT_ESP32C5,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from esphome import automation
|
||||
from collections.abc import Callable
|
||||
|
||||
from esphome import automation, preferences
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
@@ -8,13 +10,16 @@ from esphome.const import (
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_INITIAL_STATE,
|
||||
CONF_INVERTED,
|
||||
CONF_MQTT_ID,
|
||||
CONF_ON_STATE,
|
||||
CONF_ON_TURN_OFF,
|
||||
CONF_ON_TURN_ON,
|
||||
CONF_RESTORE_MODE,
|
||||
CONF_RESTORE_STATE,
|
||||
CONF_STATE,
|
||||
CONF_STORAGE,
|
||||
CONF_WEB_SERVER,
|
||||
DEVICE_CLASS_EMPTY,
|
||||
DEVICE_CLASS_OUTLET,
|
||||
@@ -22,10 +27,14 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
RESTORE_STATE_INITIAL,
|
||||
RESTORE_STATE_INVERT,
|
||||
RESTORE_STATE_KEEP,
|
||||
ApplyEntity,
|
||||
SubEntities,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
restore_state_schema,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
)
|
||||
@@ -47,6 +56,8 @@ SwitchPtr = Switch.operator("ptr")
|
||||
ApplySwitch = switch_ns.class_("ApplySwitch", Switch)
|
||||
|
||||
SwitchRestoreMode = switch_ns.enum("SwitchRestoreMode")
|
||||
RESTORE_MODE_STORAGE_FLASH_MASK = switch_ns.RESTORE_MODE_STORAGE_FLASH_MASK
|
||||
RESTORE_MODE_STORAGE_RTC_MASK = switch_ns.RESTORE_MODE_STORAGE_RTC_MASK
|
||||
|
||||
RESTORE_MODES = {
|
||||
"RESTORE_DEFAULT_OFF": SwitchRestoreMode.SWITCH_RESTORE_DEFAULT_OFF,
|
||||
@@ -60,6 +71,115 @@ RESTORE_MODES = {
|
||||
|
||||
|
||||
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True)
|
||||
validate_restore_mode = cv.enum(RESTORE_MODES, upper=True, space="_")
|
||||
|
||||
|
||||
def _validate_restore_state_storage(config: ConfigType) -> ConfigType:
|
||||
if CONF_STORAGE in config and config[CONF_STATE] == RESTORE_STATE_INITIAL:
|
||||
raise cv.Invalid(
|
||||
"'storage' only applies when a saved state is restored (KEEP or INVERT)",
|
||||
path=[CONF_STORAGE],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _validate_restore_state_state(value: object) -> str:
|
||||
# light accepts ON/OFF here (restore, then force a state); a switch has no such mode, and
|
||||
# YAML turns ON into True, so say what to use instead of asking for quotes
|
||||
if isinstance(value, bool) or (
|
||||
isinstance(value, str) and value.upper() in ("ON", "OFF")
|
||||
):
|
||||
raise cv.Invalid(
|
||||
"a switch can't restore and then force ON or OFF; to always start in a fixed state, "
|
||||
"use 'initial_state: ON' (or OFF) with 'restore_state: none'"
|
||||
)
|
||||
return cv.one_of(
|
||||
RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL, upper=True
|
||||
)(value)
|
||||
|
||||
|
||||
_RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_STATE, default=RESTORE_STATE_KEEP
|
||||
): _validate_restore_state_state,
|
||||
# No default: when unset, the existing restore_mode storage applies (on ESP8266,
|
||||
# restore_from_flash decides), as with wifi
|
||||
cv.Optional(CONF_STORAGE): preferences.validate_storage,
|
||||
}
|
||||
)
|
||||
_RESTORE_STATE_FIELDS_SCHEMA.add_extra(_validate_restore_state_storage)
|
||||
|
||||
|
||||
# A mapping of `state` and `storage`, or the shorthands `all` (KEEP) and `none` (INITIAL)
|
||||
RESTORE_STATE_SCHEMA = restore_state_schema(
|
||||
_RESTORE_STATE_FIELDS_SCHEMA, {CONF_STATE: RESTORE_STATE_INITIAL}
|
||||
)
|
||||
|
||||
|
||||
def _validate_initial_state(config: ConfigType) -> ConfigType:
|
||||
if CONF_INITIAL_STATE in config and CONF_RESTORE_MODE in config:
|
||||
raise cv.Invalid(
|
||||
"'initial_state' can't be combined with 'restore_mode', which already sets "
|
||||
"the boot state; use 'restore_state' instead",
|
||||
path=[CONF_INITIAL_STATE],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# (restore_state state, initial_state) -> restore mode
|
||||
_RESTORE_MODE_FOR_STATE = {
|
||||
(RESTORE_STATE_KEEP, False): "RESTORE_DEFAULT_OFF",
|
||||
(RESTORE_STATE_KEEP, True): "RESTORE_DEFAULT_ON",
|
||||
(RESTORE_STATE_INVERT, False): "RESTORE_INVERTED_DEFAULT_OFF",
|
||||
(RESTORE_STATE_INVERT, True): "RESTORE_INVERTED_DEFAULT_ON",
|
||||
(RESTORE_STATE_INITIAL, False): "ALWAYS_OFF",
|
||||
(RESTORE_STATE_INITIAL, True): "ALWAYS_ON",
|
||||
}
|
||||
_STATE_FOR_RESTORE_MODE = {
|
||||
mode: state for (state, _), mode in _RESTORE_MODE_FOR_STATE.items()
|
||||
}
|
||||
|
||||
|
||||
def _apply_default_restore_mode(
|
||||
default_restore_mode: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
# cv.Exclusive has no default, so apply it here when no restore option is configured.
|
||||
# With only initial_state set, a restoring default keeps restoring and initial_state
|
||||
# supplies the state used when nothing is saved.
|
||||
default = validate_restore_mode(default_restore_mode)
|
||||
state = _STATE_FOR_RESTORE_MODE.get(str(default), RESTORE_STATE_INITIAL)
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
if CONF_RESTORE_MODE in config or CONF_RESTORE_STATE in config:
|
||||
return config
|
||||
if CONF_INITIAL_STATE in config:
|
||||
config[CONF_RESTORE_STATE] = {CONF_STATE: state}
|
||||
else:
|
||||
config[CONF_RESTORE_MODE] = default
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def _resolve_restore_mode(config: ConfigType) -> MockObj | str:
|
||||
"""Translate `initial_state` / `restore_state` into one of the RESTORE_MODES, with the storage bit."""
|
||||
if (restore_mode := config.get(CONF_RESTORE_MODE)) is not None:
|
||||
return restore_mode
|
||||
restore_state = config.get(CONF_RESTORE_STATE, {})
|
||||
state = restore_state.get(CONF_STATE, RESTORE_STATE_INITIAL)
|
||||
mode = RESTORE_MODES[
|
||||
_RESTORE_MODE_FOR_STATE[state, config.get(CONF_INITIAL_STATE, False)]
|
||||
]
|
||||
if (storage := restore_state.get(CONF_STORAGE)) is None:
|
||||
return mode
|
||||
cg.add_define("USE_SWITCH_STORAGE")
|
||||
mask = (
|
||||
RESTORE_MODE_STORAGE_FLASH_MASK
|
||||
if preferences.is_in_flash(storage)
|
||||
else RESTORE_MODE_STORAGE_RTC_MASK
|
||||
)
|
||||
return cg.RawExpression(f"static_cast<{SwitchRestoreMode}>({mode} | {mask})")
|
||||
|
||||
|
||||
_SWITCH_SCHEMA = (
|
||||
@@ -70,9 +190,9 @@ _SWITCH_SCHEMA = (
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSwitchComponent),
|
||||
cv.Optional(CONF_INVERTED): cv.boolean,
|
||||
cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum(
|
||||
RESTORE_MODES, upper=True, space="_"
|
||||
),
|
||||
cv.Exclusive(CONF_RESTORE_MODE, "restore"): validate_restore_mode,
|
||||
cv.Exclusive(CONF_RESTORE_STATE, "restore"): RESTORE_STATE_SCHEMA,
|
||||
cv.Optional(CONF_INITIAL_STATE): cv.boolean,
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}),
|
||||
@@ -86,6 +206,7 @@ _SWITCH_SCHEMA = (
|
||||
|
||||
_SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch"))
|
||||
_SWITCH_SCHEMA.add_extra(zigbee.validate_switch)
|
||||
_SWITCH_SCHEMA.add_extra(_validate_initial_state)
|
||||
|
||||
|
||||
def switch_schema(
|
||||
@@ -103,13 +224,6 @@ def switch_schema(
|
||||
(CONF_DEVICE_CLASS, device_class, validate_device_class),
|
||||
(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_restore_mode is not cv.UNDEFINED
|
||||
else cv.UNDEFINED,
|
||||
),
|
||||
]:
|
||||
if default is not cv.UNDEFINED:
|
||||
schema[cv.Optional(key, default=default)] = validator
|
||||
@@ -119,7 +233,15 @@ def switch_schema(
|
||||
"Inverted is not supported for this platform!"
|
||||
)
|
||||
|
||||
return _SWITCH_SCHEMA.extend(schema)
|
||||
result = _SWITCH_SCHEMA.extend(schema)
|
||||
result.add_extra(
|
||||
_apply_default_restore_mode(
|
||||
"ALWAYS_OFF"
|
||||
if default_restore_mode is cv.UNDEFINED
|
||||
else default_restore_mode
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
_CALLBACK_AUTOMATIONS = (
|
||||
@@ -159,7 +281,7 @@ async def setup_switch_core_(var, config):
|
||||
|
||||
setup_device_class(config)
|
||||
|
||||
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))
|
||||
cg.add(var.set_restore_mode(_resolve_restore_mode(config)))
|
||||
await zigbee.setup_switch(var, config)
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,14 @@ optional<bool> Switch::get_initial_state() {
|
||||
if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK))
|
||||
return {};
|
||||
|
||||
this->rtc_ = this->make_entity_preference<bool>();
|
||||
#ifdef USE_SWITCH_STORAGE
|
||||
if (restore_mode & RESTORE_MODE_STORAGE_MASK) {
|
||||
this->rtc_ = this->make_entity_preference<bool>(0, restore_mode & RESTORE_MODE_STORAGE_FLASH_MASK);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
this->rtc_ = this->make_entity_preference<bool>();
|
||||
}
|
||||
bool initial_state;
|
||||
if (!this->rtc_.load(&initial_state))
|
||||
return {};
|
||||
@@ -53,6 +60,13 @@ optional<bool> Switch::get_initial_state_with_restore_mode() {
|
||||
}
|
||||
return initial_state;
|
||||
}
|
||||
void Switch::restore() {
|
||||
if (auto initial = this->get_initial_state_with_restore_mode())
|
||||
this->control(*initial);
|
||||
}
|
||||
void Switch::restore(bool when_disabled) {
|
||||
this->control(this->get_initial_state_with_restore_mode().value_or(when_disabled));
|
||||
}
|
||||
void Switch::publish_state(bool state) {
|
||||
if (!this->publish_dedup_.next(state))
|
||||
return;
|
||||
|
||||
@@ -14,11 +14,16 @@ namespace esphome::switch_ {
|
||||
public: \
|
||||
void set_##name##_switch(switch_::Switch *s) { this->name##_switch_ = s; }
|
||||
|
||||
// bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled
|
||||
// bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled.
|
||||
// bit4: restore from flash. bit5: restore from RTC memory (neither: platform default).
|
||||
// Code generation ors the storage bit into the restore mode passed to set_restore_mode().
|
||||
constexpr int RESTORE_MODE_ON_MASK = 0x01;
|
||||
constexpr int RESTORE_MODE_PERSISTENT_MASK = 0x02;
|
||||
constexpr int RESTORE_MODE_INVERTED_MASK = 0x04;
|
||||
constexpr int RESTORE_MODE_DISABLED_MASK = 0x08;
|
||||
constexpr int RESTORE_MODE_STORAGE_FLASH_MASK = 0x10;
|
||||
constexpr int RESTORE_MODE_STORAGE_RTC_MASK = 0x20;
|
||||
constexpr int RESTORE_MODE_STORAGE_MASK = RESTORE_MODE_STORAGE_FLASH_MASK | RESTORE_MODE_STORAGE_RTC_MASK;
|
||||
|
||||
enum SwitchRestoreMode : uint8_t {
|
||||
SWITCH_ALWAYS_OFF = !RESTORE_MODE_ON_MASK,
|
||||
@@ -110,6 +115,11 @@ class Switch : public EntityBase {
|
||||
*/
|
||||
optional<bool> get_initial_state_with_restore_mode();
|
||||
|
||||
/// Drive the switch to the state its restore mode yields, through control(), so inversion and
|
||||
/// write_state() apply; a disabled restore mode drives \p when_disabled, or nothing without one.
|
||||
void restore();
|
||||
void restore(bool when_disabled);
|
||||
|
||||
/** Return whether this switch uses an assumed state - i.e. if both the ON/OFF actions should be displayed in Home
|
||||
* Assistant because the real state is unknown.
|
||||
*
|
||||
@@ -139,9 +149,9 @@ class Switch : public EntityBase {
|
||||
LazyCallbackManager<void(bool)> state_callback_{};
|
||||
|
||||
// Small types grouped together
|
||||
Deduplicator<bool> publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_)
|
||||
Deduplicator<bool> publish_dedup_; // 3 bytes (bool has_value_ + bool value_unknown_ + bool last_value_)
|
||||
bool inverted_{false}; // 1 byte
|
||||
// Total: 3 bytes, 1 byte padding
|
||||
// Total: 4 bytes, no padding
|
||||
};
|
||||
|
||||
#define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj))
|
||||
|
||||
@@ -37,13 +37,7 @@ void TemplateSwitch::setup() {
|
||||
if (!this->f_.has_value())
|
||||
this->disable_loop();
|
||||
|
||||
optional<bool> initial_state = this->get_initial_state_with_restore_mode();
|
||||
|
||||
if (initial_state.has_value()) {
|
||||
ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value()));
|
||||
// if it has a value, restore_mode is not "DISABLED", therefore act on the switch:
|
||||
this->control(initial_state.value());
|
||||
}
|
||||
this->restore();
|
||||
}
|
||||
void TemplateSwitch::dump_config() {
|
||||
LOG_SWITCH("", "Template Switch", this);
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.components.esp32 import (
|
||||
get_esp32_variant,
|
||||
idf_version,
|
||||
only_on_variant,
|
||||
require_idf_version,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DEVICES, CONF_FRAMEWORK, CONF_ID
|
||||
@@ -201,10 +202,9 @@ def fifo_split() -> tuple[int, int, int] | None:
|
||||
def _reconcile_fifo() -> None:
|
||||
if (split := fifo_split()) is None:
|
||||
return
|
||||
if idf_version() < FIFO_CUSTOM_MIN_IDF:
|
||||
raise cv.Invalid(
|
||||
f"Sizing the USB FIFO on the {get_esp32_variant()} needs ESP-IDF {FIFO_CUSTOM_MIN_IDF} or later"
|
||||
)
|
||||
require_idf_version(
|
||||
FIFO_CUSTOM_MIN_IDF, f"Sizing the USB FIFO on the {get_esp32_variant()}"
|
||||
)(None)
|
||||
rx, nptx, ptx = split
|
||||
cg.add_define("USE_USB_HOST_FIFO_CONFIG")
|
||||
cg.add_define("USB_HOST_FIFO_RX_LINES", rx)
|
||||
|
||||
@@ -9,9 +9,9 @@ from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
const,
|
||||
get_esp32_variant,
|
||||
idf_version,
|
||||
only_on_variant,
|
||||
request_wifi,
|
||||
require_idf_version,
|
||||
require_mbedtls_tls,
|
||||
require_mbedtls_tls_extras,
|
||||
)
|
||||
@@ -232,8 +232,8 @@ def _validate_ap_mode(value: str) -> str:
|
||||
def _validate_dns_servers(value: list) -> list:
|
||||
if any(ip.is_unspecified for ip in value):
|
||||
raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer")
|
||||
if len(value) > 1 and CORE.is_esp32 and idf_version() < cv.Version(6, 0, 0):
|
||||
raise cv.Invalid("The access point offers a second DNS server from ESP-IDF 6.0")
|
||||
if len(value) > 1:
|
||||
require_idf_version(cv.Version(6, 0, 0), "A second DNS server")(value)
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -297,6 +297,7 @@
|
||||
#define USE_STATUS_LED
|
||||
#define USE_STATUS_SENSOR
|
||||
#define USE_SWITCH
|
||||
#define USE_SWITCH_STORAGE
|
||||
#define USE_TEXT
|
||||
#define USE_TEXT_SENSOR
|
||||
#define USE_TEXT_SENSOR_FILTER
|
||||
|
||||
@@ -42,6 +42,7 @@ from esphome.helpers import (
|
||||
sanitize,
|
||||
snake_case,
|
||||
)
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -764,3 +765,50 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
# restore_state: keywords, shared by the entity domains that save their state
|
||||
RESTORE_STATE_KEEP = "KEEP"
|
||||
RESTORE_STATE_INVERT = "INVERT"
|
||||
RESTORE_STATE_INITIAL = "INITIAL"
|
||||
RESTORE_STATE_ALL = "ALL"
|
||||
RESTORE_STATE_NONE = "NONE"
|
||||
|
||||
|
||||
def restore_state_keyword(value: Any, keywords: tuple[str, ...]) -> str | None:
|
||||
"""The upper-cased keyword when `value` spells one of `keywords`, else None."""
|
||||
if isinstance(value, str):
|
||||
upper = value.strip().upper()
|
||||
if upper in keywords:
|
||||
return upper
|
||||
return None
|
||||
|
||||
|
||||
def restore_state_schema(fields_schema: cv.Schema, none: Any) -> Callable[[Any], Any]:
|
||||
"""The validator for a domain's `restore_state:` key.
|
||||
|
||||
A mapping is validated with `fields_schema`. The shorthand `all` is the empty mapping,
|
||||
so every field keeps its saved value, and `none` is returned as it is; a mapping goes through
|
||||
`fields_schema` like one the user wrote, so each config gets its own copy.
|
||||
"""
|
||||
|
||||
@schema_extractor("schema")
|
||||
def validate(value: Any) -> Any:
|
||||
if value == SCHEMA_EXTRACT:
|
||||
# The shorthands have no representation in the extracted schema
|
||||
return fields_schema
|
||||
keyword = restore_state_keyword(value, (RESTORE_STATE_ALL, RESTORE_STATE_NONE))
|
||||
if keyword == RESTORE_STATE_ALL:
|
||||
value = {}
|
||||
elif keyword == RESTORE_STATE_NONE:
|
||||
if not isinstance(none, dict):
|
||||
return none
|
||||
value = none
|
||||
elif isinstance(value, str):
|
||||
raise cv.Invalid(
|
||||
f"Unknown restore_state '{value}'; expected 'all', 'none' or a mapping, "
|
||||
"for example '{state: INVERT}'"
|
||||
)
|
||||
return fields_schema(value)
|
||||
|
||||
return validate
|
||||
|
||||
@@ -32,8 +32,10 @@ from esphome.components.esp32 import (
|
||||
_user_sdkconfig_wants_tls,
|
||||
allow_insecure_tls,
|
||||
idf_version,
|
||||
require_idf_version,
|
||||
)
|
||||
from esphome.components.esp32.const import (
|
||||
KEY_BOARD,
|
||||
KEY_ESP32,
|
||||
KEY_EXCLUDE_COMPONENTS,
|
||||
KEY_IDF_VERSION,
|
||||
@@ -2442,3 +2444,44 @@ def test_allow_insecure_tls_only_switches_the_flags_on(
|
||||
"CONFIG_ESP_TLS_INSECURE": True,
|
||||
"CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_framework", "idf", "ok"),
|
||||
[
|
||||
(PlatformFramework.ESP32_IDF, cv.Version(5, 5, 3), True),
|
||||
(PlatformFramework.ESP32_IDF, cv.Version(5, 5, 2), False),
|
||||
(PlatformFramework.ESP32_ARDUINO, cv.Version(5, 5, 3), True),
|
||||
(PlatformFramework.ESP32_ARDUINO, cv.Version(5, 5, 2), False),
|
||||
],
|
||||
)
|
||||
def test_require_idf_version_compares_the_idf_underneath(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform_framework: PlatformFramework,
|
||||
idf: cv.Version,
|
||||
ok: bool,
|
||||
) -> None:
|
||||
set_core_config(
|
||||
platform_framework,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32dev",
|
||||
KEY_VARIANT: VARIANT_ESP32,
|
||||
KEY_IDF_VERSION: idf,
|
||||
},
|
||||
)
|
||||
validator = require_idf_version(cv.Version(5, 5, 3), "The thing")
|
||||
if ok:
|
||||
assert validator("x") == "x"
|
||||
else:
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="The thing requires ESP-IDF 5.5.3 or newer, got 5.5.2"
|
||||
):
|
||||
validator("x")
|
||||
|
||||
|
||||
def test_require_idf_version_rejects_other_platforms(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP8266_ARDUINO)
|
||||
with pytest.raises(cv.Invalid, match="only available on"):
|
||||
require_idf_version(cv.Version(5, 5, 3))("x")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- {platform: template, id: legacy, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF}
|
||||
- {platform: template, id: plain, optimistic: true}
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- {platform: template, id: keep_rtc, optimistic: true, initial_state: ON, restore_state: {state: KEEP, storage: rtc}}
|
||||
- {platform: template, id: keep_flash, optimistic: true, restore_state: {state: KEEP, storage: flash}}
|
||||
- {platform: template, id: keep_short, optimistic: true, restore_state: {state: KEEP}}
|
||||
- {platform: template, id: all_short, optimistic: true, restore_state: ALL}
|
||||
- {platform: template, id: invert_on, optimistic: true, initial_state: ON, restore_state: {state: INVERT}}
|
||||
- {platform: template, id: initial_on, optimistic: true, initial_state: ON, restore_state: {state: INITIAL}}
|
||||
- {platform: template, id: none_on, optimistic: true, initial_state: ON, restore_state: NONE}
|
||||
- {platform: template, id: initial_only, optimistic: true, initial_state: ON}
|
||||
- {platform: template, id: legacy, optimistic: true, restore_mode: RESTORE_DEFAULT_OFF}
|
||||
- {platform: template, id: plain, optimistic: true}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp12e
|
||||
restore_from_flash: true
|
||||
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- {platform: template, id: keep_rtc, optimistic: true, initial_state: ON, restore_state: {state: KEEP, storage: rtc}}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Tests for switch `initial_state` / `restore_state` (including `storage`)."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import switch
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231T,
|
||||
FAMILY_BK7251,
|
||||
FAMILY_LN882H,
|
||||
FAMILY_RTL8720C,
|
||||
KEY_FAMILY,
|
||||
KEY_LIBRETINY,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
GATE = "USE_SWITCH_STORAGE"
|
||||
ESP32_RTC = "USE_ESP32_RTC_PREFERENCES"
|
||||
|
||||
|
||||
def _defines() -> set[str]:
|
||||
return {define.name for define in CORE.defines}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("switch_id", "mode"),
|
||||
[
|
||||
("keep_short", "SWITCH_RESTORE_DEFAULT_OFF"),
|
||||
("all_short", "SWITCH_RESTORE_DEFAULT_OFF"),
|
||||
("invert_on", "SWITCH_RESTORE_INVERTED_DEFAULT_ON"),
|
||||
("initial_on", "SWITCH_ALWAYS_ON"),
|
||||
("none_on", "SWITCH_ALWAYS_ON"),
|
||||
("initial_only", "SWITCH_ALWAYS_ON"),
|
||||
("legacy", "SWITCH_RESTORE_DEFAULT_OFF"),
|
||||
("plain", "SWITCH_ALWAYS_OFF"),
|
||||
],
|
||||
)
|
||||
def test_restore_state_maps_to_a_restore_mode(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
switch_id: str,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""Every initial_state / restore_state combination becomes one existing restore mode."""
|
||||
main_cpp = generate_main(component_config_path("restore_state_esp32.yaml"))
|
||||
|
||||
assert f"{switch_id}->set_restore_mode(switch_::{mode});" in main_cpp
|
||||
|
||||
|
||||
def test_storage_is_passed_only_when_set(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("restore_state_esp32.yaml"))
|
||||
|
||||
assert (
|
||||
"keep_rtc->set_restore_mode(static_cast<switch_::SwitchRestoreMode>("
|
||||
"switch_::SWITCH_RESTORE_DEFAULT_ON | switch_::RESTORE_MODE_STORAGE_RTC_MASK));"
|
||||
) in main_cpp
|
||||
assert (
|
||||
"keep_flash->set_restore_mode(static_cast<switch_::SwitchRestoreMode>("
|
||||
"switch_::SWITCH_RESTORE_DEFAULT_OFF | switch_::RESTORE_MODE_STORAGE_FLASH_MASK));"
|
||||
) in main_cpp
|
||||
assert (
|
||||
"keep_short->set_restore_mode(switch_::SWITCH_RESTORE_DEFAULT_OFF);" in main_cpp
|
||||
)
|
||||
assert GATE in _defines()
|
||||
assert ESP32_RTC in _defines()
|
||||
|
||||
|
||||
def test_nothing_new_emitted_without_restore_state(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Configs using only restore_mode generate exactly what they did before."""
|
||||
main_cpp = generate_main(component_config_path("restore_state_absent_esp32.yaml"))
|
||||
|
||||
assert "RESTORE_MODE_STORAGE_" not in main_cpp
|
||||
assert GATE not in _defines()
|
||||
assert ESP32_RTC not in _defines()
|
||||
|
||||
|
||||
def test_rtc_applies_per_switch_on_esp8266_with_restore_from_flash(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("restore_state_esp8266.yaml"))
|
||||
|
||||
assert (
|
||||
"keep_rtc->set_restore_mode(static_cast<switch_::SwitchRestoreMode>("
|
||||
"switch_::SWITCH_RESTORE_DEFAULT_ON | switch_::RESTORE_MODE_STORAGE_RTC_MASK));"
|
||||
) in main_cpp
|
||||
assert ESP32_RTC not in _defines()
|
||||
|
||||
|
||||
def _validate(config: dict, *, default_restore_mode: str = cv.UNDEFINED) -> dict:
|
||||
schema = switch.switch_schema(
|
||||
switch.Switch, default_restore_mode=default_restore_mode
|
||||
)
|
||||
return schema({"id": "test_switch", **config})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def esp8266(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(PlatformFramework.ESP8266_ARDUINO)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_restore_mode_and_restore_state_are_exclusive() -> None:
|
||||
with pytest.raises(cv.Invalid, match="restore"):
|
||||
_validate({"restore_mode": "RESTORE_DEFAULT_OFF", "restore_state": "all"})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_initial_state_rejected_with_restore_mode() -> None:
|
||||
"""restore_mode already decides the boot state, so initial_state would be ignored."""
|
||||
with pytest.raises(cv.Invalid, match="initial_state"):
|
||||
_validate({"restore_mode": "ALWAYS_OFF", "initial_state": True})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_storage_rejected_when_nothing_is_restored() -> None:
|
||||
with pytest.raises(cv.Invalid, match="only applies"):
|
||||
_validate({"restore_state": {"state": "INITIAL", "storage": "rtc"}})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
@pytest.mark.parametrize("value", ["RESTORE", "ON", "OFF", "KEEP", "INVERT", "INITIAL"])
|
||||
def test_unsupported_restore_state_values_rejected(value: object) -> None:
|
||||
# Only light's shorthands (all, none) are accepted as a string
|
||||
with pytest.raises(cv.Invalid, match="expected 'all', 'none' or a mapping"):
|
||||
_validate({"restore_state": value})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
@pytest.mark.parametrize("state", [True, False, "ON", "off"])
|
||||
def test_restore_state_on_off_explains_what_to_use(state: object) -> None:
|
||||
# Valid on light, so people will try it; YAML turns ON into True
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="initial_state: ON' \\(or OFF\\) with 'restore_state: none'"
|
||||
):
|
||||
_validate({"restore_state": {"state": state}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "family"),
|
||||
[
|
||||
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7251),
|
||||
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C),
|
||||
(PlatformFramework.LN882X_ARDUINO, FAMILY_LN882H),
|
||||
(PlatformFramework.RP2040_ARDUINO, None),
|
||||
(PlatformFramework.NRF52_ZEPHYR, None),
|
||||
(PlatformFramework.HOST_NATIVE, None),
|
||||
],
|
||||
)
|
||||
def test_rtc_rejected_without_an_rtc_backend(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform: PlatformFramework,
|
||||
family: str | None,
|
||||
) -> None:
|
||||
"""These preferences backends ignore in_flash, so the state would silently stay in flash."""
|
||||
set_core_config(platform)
|
||||
if family is not None:
|
||||
CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family}
|
||||
with pytest.raises(cv.Invalid, match="'rtc' storage is not supported"):
|
||||
_validate({"restore_state": {"state": "KEEP", "storage": "rtc"}})
|
||||
|
||||
|
||||
def test_rtc_accepted_on_a_beken_chip_with_the_emulation(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: FAMILY_BK7231T}
|
||||
_validate({"restore_state": {"state": "KEEP", "storage": "rtc"}})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_rtc_accepted_on_esp8266() -> None:
|
||||
_validate({"restore_state": {"state": "KEEP", "storage": "rtc"}})
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
def test_platform_default_restore_mode_applies_only_when_nothing_is_set() -> None:
|
||||
"""Platforms such as haier default to DISABLED; restore_state must still be usable there."""
|
||||
assert _validate({}, default_restore_mode="DISABLED")["restore_mode"] == "DISABLED"
|
||||
configured = _validate({"restore_state": "all"}, default_restore_mode="DISABLED")
|
||||
assert "restore_mode" not in configured
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("esp8266")
|
||||
@pytest.mark.parametrize(
|
||||
("default_restore_mode", "initial_state", "expected"),
|
||||
[
|
||||
# A restoring default keeps restoring; initial_state only replaces its fallback
|
||||
("RESTORE_DEFAULT_ON", False, "RESTORE_DEFAULT_OFF"),
|
||||
("RESTORE_DEFAULT_OFF", True, "RESTORE_DEFAULT_ON"),
|
||||
("RESTORE_INVERTED_DEFAULT_OFF", True, "RESTORE_INVERTED_DEFAULT_ON"),
|
||||
# A default that restores nothing starts in initial_state
|
||||
("ALWAYS_OFF", True, "ALWAYS_ON"),
|
||||
("DISABLED", True, "ALWAYS_ON"),
|
||||
],
|
||||
)
|
||||
def test_initial_state_alone_keeps_a_restoring_platform_default(
|
||||
default_restore_mode: str, initial_state: bool, expected: str
|
||||
) -> None:
|
||||
config = _validate(
|
||||
{"initial_state": initial_state}, default_restore_mode=default_restore_mode
|
||||
)
|
||||
assert "restore_mode" not in config
|
||||
assert "storage" not in config.get("restore_state", {})
|
||||
assert switch._resolve_restore_mode(config) is switch.RESTORE_MODES[expected]
|
||||
@@ -168,5 +168,5 @@ def test_final_validate_needs_idf_5_5(
|
||||
) -> None:
|
||||
_set_variant(set_core_config, VARIANT_ESP32S3, idf=idf)
|
||||
usb_host.request_periodic_fifo(out_bytes=196)
|
||||
with pytest.raises(cv.Invalid, match="needs ESP-IDF 5.5.0 or later"):
|
||||
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.5.0 or newer"):
|
||||
usb_host.FINAL_VALIDATE_SCHEMA({})
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# The gtest binary has no YAML switch to emit USE_SWITCH_STORAGE, so define it here.
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
cg.add_define("USE_SWITCH_STORAGE")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -21,6 +21,13 @@ switch:
|
||||
- logger.log: "Switch is now ON"
|
||||
on_turn_off:
|
||||
- logger.log: "Switch is now OFF"
|
||||
- platform: template
|
||||
name: "Flash Storage Switch"
|
||||
id: flash_storage_switch
|
||||
optimistic: true
|
||||
restore_state:
|
||||
state: KEEP
|
||||
storage: flash
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifdef USE_HOST
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include "esphome/components/host/preferences.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::switch_::testing {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
/// Switch is abstract; this is the minimum needed to instantiate one.
|
||||
class TestSwitch : public Switch {
|
||||
public:
|
||||
void write_state(bool state) override {}
|
||||
/// The preference key is derived from object_id_hash_; two instances with the same value share stored state.
|
||||
void set_test_id(uint32_t hash) { this->object_id_hash_ = hash; }
|
||||
};
|
||||
|
||||
class SwitchRestoreStorageTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
this->temp_dir_ = fs::temp_directory_path() / "esphome_switch_storage_test";
|
||||
fs::remove_all(this->temp_dir_);
|
||||
fs::create_directories(this->temp_dir_);
|
||||
setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1);
|
||||
App.pre_setup("test_switch_storage", strlen("test_switch_storage"), "", 0);
|
||||
host::setup_preferences();
|
||||
// Host preferences also keep an in-memory store, which would leak between tests and GTEST_REPEAT iterations.
|
||||
host::host_preferences->reset();
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
unsetenv("ESPHOME_PREFDIR");
|
||||
fs::remove_all(this->temp_dir_);
|
||||
}
|
||||
|
||||
fs::path temp_dir_;
|
||||
};
|
||||
|
||||
// Host ignores flash vs RTC, so this checks that a switch with a storage bit in its restore mode still binds,
|
||||
// saves and restores its preference; which memory it lands in is checked by the ESP32/ESP8266 compile tests.
|
||||
TEST_F(SwitchRestoreStorageTest, SwitchWithAStorageChoiceStillRestoresItsState) {
|
||||
static constexpr uint32_t ID = 0x5001;
|
||||
const auto mode = static_cast<SwitchRestoreMode>(SWITCH_RESTORE_DEFAULT_OFF | RESTORE_MODE_STORAGE_RTC_MASK);
|
||||
{
|
||||
TestSwitch sw;
|
||||
sw.set_test_id(ID);
|
||||
sw.set_restore_mode(mode);
|
||||
auto first = sw.get_initial_state_with_restore_mode();
|
||||
ASSERT_TRUE(first.has_value());
|
||||
EXPECT_FALSE(first.value()) << "nothing stored yet, so RESTORE_DEFAULT_OFF gives OFF";
|
||||
sw.publish_state(true);
|
||||
global_preferences->sync();
|
||||
}
|
||||
TestSwitch sw2;
|
||||
sw2.set_test_id(ID);
|
||||
sw2.set_restore_mode(mode);
|
||||
auto restored = sw2.get_initial_state_with_restore_mode();
|
||||
ASSERT_TRUE(restored.has_value());
|
||||
EXPECT_TRUE(restored.value()) << "the saved state was not restored";
|
||||
}
|
||||
|
||||
} // namespace esphome::switch_::testing
|
||||
#endif // USE_HOST
|
||||
@@ -0,0 +1,49 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/switch/switch.h"
|
||||
|
||||
namespace esphome::switch_::testing {
|
||||
|
||||
class RecordingSwitch : public Switch {
|
||||
public:
|
||||
std::vector<bool> writes;
|
||||
|
||||
protected:
|
||||
void write_state(bool state) override {
|
||||
this->writes.push_back(state);
|
||||
this->publish_state(state);
|
||||
}
|
||||
};
|
||||
|
||||
TEST(SwitchRestore, DrivesTheRestoreModeStateThroughInversion) {
|
||||
RecordingSwitch on, off, inverted;
|
||||
on.set_restore_mode(SWITCH_ALWAYS_ON);
|
||||
off.set_restore_mode(SWITCH_ALWAYS_OFF);
|
||||
inverted.set_restore_mode(SWITCH_ALWAYS_ON);
|
||||
inverted.set_inverted(true);
|
||||
on.restore();
|
||||
off.restore();
|
||||
inverted.restore();
|
||||
EXPECT_EQ(on.writes, std::vector<bool>{true});
|
||||
EXPECT_EQ(off.writes, std::vector<bool>{false});
|
||||
EXPECT_EQ(inverted.writes, std::vector<bool>{false}); // the hardware level
|
||||
EXPECT_TRUE(on.state);
|
||||
EXPECT_FALSE(off.state);
|
||||
EXPECT_TRUE(inverted.state);
|
||||
}
|
||||
|
||||
TEST(SwitchRestore, DefaultAppliesOnlyWhenTheModeIsDisabled) {
|
||||
RecordingSwitch disabled, on;
|
||||
disabled.set_restore_mode(SWITCH_RESTORE_DISABLED);
|
||||
on.set_restore_mode(SWITCH_ALWAYS_ON);
|
||||
disabled.restore();
|
||||
EXPECT_TRUE(disabled.writes.empty());
|
||||
disabled.restore(true);
|
||||
on.restore(false);
|
||||
EXPECT_EQ(disabled.writes, std::vector<bool>{true});
|
||||
EXPECT_EQ(on.writes, std::vector<bool>{true});
|
||||
}
|
||||
|
||||
} // namespace esphome::switch_::testing
|
||||
@@ -0,0 +1,8 @@
|
||||
switch:
|
||||
- platform: template
|
||||
name: "RTC Storage Switch"
|
||||
id: rtc_storage_switch
|
||||
optimistic: true
|
||||
initial_state: ON
|
||||
restore_state:
|
||||
storage: rtc
|
||||
@@ -1,2 +1,3 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rtc_storage: !include rtc_storage.yaml
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
rtc_storage: !include rtc_storage.yaml
|
||||
|
||||
@@ -16,10 +16,6 @@ from esphome.components.light import (
|
||||
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,
|
||||
@@ -33,6 +29,12 @@ 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.core.entity_helpers import (
|
||||
RESTORE_STATE_INITIAL,
|
||||
RESTORE_STATE_INVERT,
|
||||
RESTORE_STATE_KEEP,
|
||||
RESTORE_STATE_NONE,
|
||||
)
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT
|
||||
|
||||
# (mode name, expected cold_boot_state, expected restore_action, expected save_enabled)
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome import final_validate as fv
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button, number, select, switch
|
||||
from esphome.config import Config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
@@ -1740,3 +1741,31 @@ def test_inherit_property_from_path_missing_is_ignored() -> None:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
assert "absent" not in config["sensor"][1]
|
||||
|
||||
|
||||
_RESTORE_FIELDS = cv.Schema({cv.Optional("state", default="KEEP"): cv.string})
|
||||
|
||||
|
||||
def test_restore_state_schema_shorthands() -> None:
|
||||
validate = entity_helpers.restore_state_schema(
|
||||
_RESTORE_FIELDS, {"state": "INITIAL"}
|
||||
)
|
||||
assert validate(" all ") == {"state": "KEEP"}
|
||||
assert validate("None") == {"state": "INITIAL"}
|
||||
assert validate({"state": "INVERT"}) == {"state": "INVERT"}
|
||||
with pytest.raises(Invalid, match="expected 'all', 'none' or a mapping"):
|
||||
validate("everything")
|
||||
|
||||
|
||||
def test_restore_state_schema_keeps_a_keyword_for_none() -> None:
|
||||
validate = entity_helpers.restore_state_schema(
|
||||
_RESTORE_FIELDS, entity_helpers.RESTORE_STATE_NONE
|
||||
)
|
||||
assert validate("none") == entity_helpers.RESTORE_STATE_NONE
|
||||
|
||||
|
||||
def test_restore_state_keyword() -> None:
|
||||
keywords = (entity_helpers.RESTORE_STATE_KEEP, entity_helpers.RESTORE_STATE_INVERT)
|
||||
assert entity_helpers.restore_state_keyword(" invert ", keywords) == "INVERT"
|
||||
assert entity_helpers.restore_state_keyword("INITIAL", keywords) is None
|
||||
assert entity_helpers.restore_state_keyword(True, keywords) is None
|
||||
|
||||
Reference in New Issue
Block a user