Compare commits

...
33 changed files with 757 additions and 101 deletions
+2
View File
@@ -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`)
+16
View File
@@ -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.
+6 -15
View File
@@ -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;
+1 -1
View File
@@ -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,
+24 -30
View File
@@ -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); }
+2 -9
View File
@@ -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,
+135 -13
View File
@@ -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)
+15 -1
View File
@@ -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;
+13 -3
View File
@@ -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);
+4 -4
View File
@@ -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)
+3 -3
View File
@@ -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
+1
View File
@@ -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
+48
View File
@@ -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
+43
View File
@@ -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]
+1 -1
View File
@@ -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({})
+11
View File
@@ -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
+7
View File
@@ -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
+49
View File
@@ -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
+8
View File
@@ -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