Compare commits

..
Author SHA1 Message Date
J. Nick Koston 1d1f1f517b Merge remote-tracking branch 'origin/ota-upload-retry' into integration3 2026-08-12 21:03:32 -05:00
J. Nick Koston fa910f0a32 Merge remote-tracking branch 'origin/esp8266_netif_down_on_disconnect' into integration3 2026-08-12 21:03:27 -05:00
J. Nick Koston 3465ee3ca9 Revert "Deduplicate resolved endpoints before sizing the attempt budget"
This reverts commit 077041e072.
2026-08-12 20:47:43 -05:00
J. Nick Koston 077041e072 Deduplicate resolved endpoints before sizing the attempt budget 2026-08-12 20:45:06 -05:00
J. Nick Koston 1dc2bc47b4 Keep MD5 mismatch non-retryable with its own error message 2026-08-12 20:34:51 -05:00
J. Nick Koston f3a464b367 Retry MD5 mismatches, log probe misses, and document the data timeout limitation 2026-08-12 20:15:16 -05:00
J. Nick Koston d5a19064ba Close the final chunk ack retry window and surface pending device errors 2026-08-12 20:03:04 -05:00
J. Nick KostonandGitHub e4728a1aa8 Merge branch 'dev' into esp8266_netif_down_on_disconnect 2026-08-12 20:00:27 -05:00
J. Nick Koston aa11809c39 Take STA netif down in authmode-downgrade disconnect path too 2026-08-12 19:28:13 -05:00
J. Nick Koston c1481bbb5d Guard against empty address list and polish review nits 2026-08-12 19:21:13 -05:00
J. Nick Koston 0fbbee2e94 [wifi] Take ESP8266 STA netif down on disconnect to stop lwIP transmit into dead driver 2026-08-12 19:02:48 -05:00
J. Nick Koston 3ee8aaf77c Address review feedback on retry behavior and diagnostics 2026-08-12 18:54:57 -05:00
J. Nick Koston 740d60a4c5 Log when the connection is established and the handshake completes 2026-08-12 18:30:39 -05:00
J. Nick Koston 9abe462173 Add tests for mid-read and chunk send network errors 2026-08-12 18:27:26 -05:00
J. Nick Koston 1072d6b070 Fold duplicate subclass tests into existing tests, unpack address tuple in loop 2026-08-12 18:25:48 -05:00
J. Nick Koston 7b0541cd23 [ota] Retry uploads that fail from network errors 2026-08-12 18:23:00 -05:00
J. Nick KostonandGitHub 51ca5ffe49 Merge branch 'dev' into web-server-base-persistent-server 2026-08-12 17:48:16 -05:00
J. Nick Koston 69bcbe3ae3 Document the persistent-server invariant and guard unbalanced deinit() 2026-08-12 16:29:31 -05:00
J. Nick Koston 965d9c940a [web_server_base] Stop deleting the web server on captive portal teardown 2026-08-12 15:57:24 -05:00
58 changed files with 1147 additions and 1244 deletions
-1
View File
@@ -179,7 +179,6 @@ jobs:
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_alias_registry.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check
-10
View File
@@ -1,10 +0,0 @@
"""Component alias registry.
Generated by script/build_alias_registry.py - do not edit manually.
See the component-alias section of esphome/loader.py.
"""
# alias -> (canonical component, removal version or None)
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
"rp2040": ("rp2", "2027.7.0"),
}
+3 -3
View File
@@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() {
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) {
msg.key = entity->get_object_id_hash();
msg.key = entity->get_entity_key();
#ifdef USE_DEVICES
msg.device_id = entity->get_device_id();
#endif
@@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) {
// Set common fields that are shared by all entity types
msg.key = entity->get_object_id_hash();
msg.key = entity->get_entity_key();
if (entity->has_own_name()) {
msg.name = entity->get_name();
@@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg;
msg.key = camera::Camera::instance()->get_object_id_hash();
msg.key = camera::Camera::instance()->get_entity_key();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
+13 -11
View File
@@ -3,6 +3,7 @@ from logging import getLogger
from esphome import automation, core
from esphome.automation import Condition, maybe_simple_id
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_ON_STATE_CHANGE
import esphome.config_validation as cv
from esphome.const import (
@@ -27,6 +28,7 @@ from esphome.const import (
CONF_STATE,
CONF_TIMING,
CONF_TRIGGER_ID,
CONF_WEB_SERVER,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_BATTERY_CHARGING,
DEVICE_CLASS_CARBON_MONOXIDE,
@@ -57,11 +59,9 @@ from esphome.const import (
DEVICE_CLASS_VIBRATION,
DEVICE_CLASS_WINDOW,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
lazy_load_validator,
mqtt_component_class,
queue_entity_register,
setup_device_class,
setup_entity,
@@ -433,14 +433,14 @@ def validate_publish_initial_state(value):
_BINARY_SENSOR_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMPONENT_SCHEMA)
.extend(entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA)
.extend(zigbee.BINARY_SENSOR_SCHEMA)
.extend(
{
cv.GenerateID(): cv.declare_id(BinarySensor),
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt_component_class("MQTTBinarySensorComponent")
mqtt.MQTTBinarySensorComponent
),
cv.Exclusive(
CONF_PUBLISH_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
@@ -505,7 +505,7 @@ _BINARY_SENSOR_SCHEMA = (
_BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor"))
_BINARY_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_binary_sensor"))
_BINARY_SENSOR_SCHEMA.add_extra(zigbee.validate_binary_sensor)
def binary_sensor_schema(
@@ -607,12 +607,14 @@ async def setup_binary_sensor_core_(var, config):
CORE.add_job(_build_binary_sensor_automations, var, config)
await entity_helpers.setup_entity_integrations(var, config)
if mqtt_id := config.get(CONF_MQTT_ID):
mqtt_ = cg.new_Pvariable(mqtt_id, var)
await mqtt.register_mqtt_component(mqtt_, config)
if "zigbee" in CORE.loaded_integrations:
from esphome.components import zigbee
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
await zigbee.setup_binary_sensor(var, config)
await zigbee.setup_binary_sensor(var, config)
async def register_binary_sensor(var, config):
+6 -14
View File
@@ -37,7 +37,7 @@ from esphome.const import (
CONF_INTERVAL,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, ID, KEY_CORE, TimePeriod
from esphome.core import CORE, ID, KEY_CORE
from esphome.types import ConfigType
CODEOWNERS = ["@Bl00d-B0b"]
@@ -243,27 +243,19 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
return config
# The historical scan window default shared by the trackers that do not pin
# their own; also the fallback for esp32's conditional default.
DEFAULT_SCAN_WINDOW = "30ms"
def scan_parameters_schema(
interval_default: str,
*,
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
window_default: str = "30ms",
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg
callable evaluated per validation when the user omits the key (esp32 uses
this to record that the window was defaulted, so a later validation step
can adjust it once sibling keys are resolved). The `active` option
(default on) is unconditional: active scanning is part of the tracker
contract — every current proxy client assumes it, so a passive-only
tracker must not share this schema.
LN882H's SDK recommends 100/50 ms). The `active` option (default on) is
unconditional: active scanning is part of the tracker contract — every
current proxy client assumes it, so a passive-only tracker must not share
this schema.
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
-2
View File
@@ -22,7 +22,6 @@ CONF_GYROSCOPE_ODR = "gyroscope_odr"
CONF_GYROSCOPE_RANGE = "gyroscope_range"
CONF_IAQ = "iaq"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_LABEL = "label"
CONF_LIBRETINY = "libretiny"
CONF_LOOP = "loop"
CONF_NOX_INDEX = "nox_index"
@@ -36,7 +35,6 @@ CONF_REQUEST_HEADERS = "request_headers"
CONF_ROWS = "rows"
CONF_SCAN_PARAMETERS = "scan_parameters"
CONF_SHA256 = "sha256"
CONF_SLOT = "slot"
CONF_STATE_SAVE_INTERVAL = "state_save_interval"
CONF_STOP_BITS = "stop_bits"
CONF_TARGET_COUNT = "target_count"
@@ -3,7 +3,6 @@ import re
from esphome import automation, core
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components.const import CONF_LABEL
from esphome.components.number import Number
from esphome.components.select import Select
from esphome.components.switch import Switch
@@ -31,6 +30,7 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base")
CONF_ROTARY = "rotary"
CONF_JOYSTICK = "joystick"
CONF_LABEL = "label"
CONF_MENU = "menu"
CONF_BACK = "back"
CONF_SELECT = "select"
-2
View File
@@ -648,8 +648,6 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init
case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init
return;
default:
@@ -1,7 +1,5 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
import logging
from esphome import automation
@@ -10,7 +8,6 @@ from esphome.components import ble_device_base, esp32_ble, ota
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
idf_version,
request_bluetooth,
request_software_coexistence,
)
@@ -38,12 +35,10 @@ from esphome.const import (
CONF_SERVICE_UUID,
CONF_TRIGGER_ID,
)
from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.enum import StrEnum
from esphome.types import ConfigType
DOMAIN = "esp32_ble_tracker"
AUTO_LOAD = ["ble_device_base", "esp32_ble"]
DEPENDENCIES = ["esp32"]
CODEOWNERS = ["@bdraco"]
@@ -130,71 +125,10 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
return config
# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far
# longer than the configured window (espressif/esp-idf#18931). Before the fix,
# the default 30 ms window in a 320 ms interval effectively scanned at a much
# higher duty cycle than requested; with the fix, that same default only
# listens 9.4 % of the time and misses most advertisements when wifi shares
# the radio. Espressif recommends setting the window equal to the interval in
# that case: the coexistence arbiter still shares the radio with wifi, and
# BLE uses the airtime wifi does not claim.
IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5)
@dataclass
class TrackerData:
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
scan_window_defaulted: bool = False
def _get_data() -> TrackerData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = TrackerData()
return CORE.data[DOMAIN]
def _scan_window_default() -> TimePeriod:
"""Schema default for the scan window.
Records that the user did not set a window, so _raise_defaulted_scan_window
can tell a defaulted 30 ms from an explicit one; the raise itself must wait
for the outer schema because it depends on software_coexistence, a sibling
key not yet resolved here.
"""
_get_data().scan_window_defaulted = True
return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW)
def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
"""Raise a defaulted scan window to the interval where that is safe.
Only when the coexistence arbiter is compiled in (software_coexistence,
present iff wifi is configured and not disabled by the user) and the IDF
honors the window strictly (>= 5.5.5); without the arbiter a full-duty
scan would starve wifi outright, and a user-set window is never touched.
Raising to the interval cannot invalidate the already-validated
parameters, so no re-validation is needed.
"""
if (
_get_data().scan_window_defaulted
and config.get(CONF_SOFTWARE_COEXISTENCE)
and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION
):
params = config[CONF_SCAN_PARAMETERS]
# Copy so the config dump shows a plain value instead of a YAML
# anchor/alias pair pointing at the interval.
params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL])
return config
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
# window/interval pairs that collapse to the same 0.625 ms unit count.
# The window default is conditional (see _scan_window_default above).
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"320ms", window_default=_scan_window_default
)
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("320ms")
# Codegen helpers are owned by ble_device_base; kept under the historical names
# here for the components that import them from this module.
@@ -249,7 +183,6 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
validate_max_connections_deprecated,
_raise_defaulted_scan_window,
)
+2 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from esphome import pins
from esphome.components import esp32
from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM
from esphome.components.const import CONF_USE_PSRAM
import esphome.config_validation as cv
from esphome.const import (
CONF_CLK_PIN,
@@ -33,6 +33,7 @@ CONF_DATA_READY_PIN = "data_ready_pin"
CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high"
CONF_HANDSHAKE_PIN = "handshake_pin"
CONF_SDIO_FREQUENCY = "sdio_frequency"
CONF_SLOT = "slot"
CONF_SPI_MODE = "spi_mode"
# Shared fields for both transport modes
+2 -6
View File
@@ -154,12 +154,8 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) {
// Forward received IR data to API server
#if defined(USE_API) && defined(USE_IR_RF)
if (api::global_api_server != nullptr) {
#ifdef USE_DEVICES
uint32_t device_id = this->get_device_id();
#else
uint32_t device_id = 0;
#endif
api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data());
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
&data.get_raw_data());
}
#endif
return false; // Don't consume the event, allow other listeners to process it
+2 -1
View File
@@ -1,4 +1,3 @@
from esphome.components.const import CONF_LABEL
import esphome.config_validation as cv
from esphome.const import CONF_TEXT
@@ -15,6 +14,8 @@ from ..schemas import TEXT_SCHEMA
from ..types import LvText
from . import Widget, WidgetType
CONF_LABEL = "label"
class LabelType(WidgetType):
def __init__(self):
+63
View File
@@ -63,6 +63,7 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import ObjectIdEntity, validate_no_object_id_conflicts
from esphome.types import ConfigType
DEPENDENCIES = ["network"]
@@ -332,6 +333,68 @@ CONFIG_SCHEMA = cv.All(
)
# Platforms whose MQTT components subscribe to an object_id-derived command topic.
# Keep in sync with the platforms extending cv.MQTT_COMMAND_COMPONENT_SCHEMA, plus
# text, whose MQTT component subscribes a command topic that cannot be overridden.
_COMMAND_TOPIC_PLATFORMS = frozenset(
{
"alarm_control_panel",
"button",
"climate",
"cover",
"datetime",
"fan",
"light",
"lock",
"number",
"select",
"switch",
"text",
"update",
"valve",
}
)
# Platforms whose MQTT components derive extra sub-topics (position/command,
# mode/command, speed/command, ...) from the object_id, each with its own config
# key; custom state and command topics cannot exempt them from conflicting.
_SUB_TOPIC_PLATFORMS = frozenset({"climate", "cover", "fan", "valve"})
def _topics_conflict(entities: list[ObjectIdEntity], config: ConfigType) -> bool:
"""Check whether more than one entity actually uses an object_id-derived topic.
An empty topic_prefix disables default topics entirely, custom state and
command topics avoid the default topics, and disabling discovery (globally
or per entity) avoids the discovery config topic.
"""
if config[CONF_TOPIC_PREFIX]:
platform = entities[0].platform
if platform in _SUB_TOPIC_PLATFORMS:
return True
if sum(CONF_STATE_TOPIC not in entity.config for entity in entities) > 1:
return True
if (
platform in _COMMAND_TOPIC_PLATFORMS
and sum(CONF_COMMAND_TOPIC not in entity.config for entity in entities) > 1
):
return True
if not config[CONF_DISCOVERY]:
return False
discovery_entities = sum(
entity.config.get(CONF_DISCOVERY, True) for entity in entities
)
return discovery_entities > 1
FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts(
"mqtt builds default topics and discovery topics from the entity object_id, "
"which is the name converted to ASCII",
conflict_filter=_topics_conflict,
)
def exp_mqtt_message(config):
if config is None:
return cg.optional(cg.TemplateArguments(MQTTMessage))
@@ -3,6 +3,7 @@ from esphome.components import web_server_base
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL
from esphome.core.entity_helpers import validate_no_object_id_conflicts
from esphome.cpp_types import EntityBase
AUTO_LOAD = ["web_server_base"]
@@ -35,6 +36,11 @@ CONFIG_SCHEMA = cv.Schema(
},
).extend(cv.COMPONENT_SCHEMA)
FINAL_VALIDATE_SCHEMA = validate_no_object_id_conflicts(
"prometheus builds metric labels from the entity object_id, "
"which is the name converted to ASCII"
)
async def to_code(config):
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])
@@ -99,12 +99,8 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) {
// Forward received RF data to API server
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
if (api::global_api_server != nullptr) {
#ifdef USE_DEVICES
uint32_t device_id = this->get_device_id();
#else
uint32_t device_id = 0;
#endif
api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data());
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
&data.get_raw_data());
}
#endif
return false; // Don't consume the event, allow other listeners to process it
@@ -3,7 +3,6 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import runtime_image
from esphome.components.const import CONF_SLOT
from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata
import esphome.config_validation as cv
from esphome.const import (
@@ -46,6 +45,7 @@ MAX_IMAGE_DIMENSION = 32767
MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60)
MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60)
CONF_SLOT = "slot"
CONF_CURRENT_IMAGE = "current_image"
CONF_TRANSITION_IMAGE = "transition_image"
CONF_ON_IMAGE_DISPLAY = "on_image_display"
+21 -21
View File
@@ -3,6 +3,7 @@ import math
from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_B_CONSTANT
import esphome.config_validation as cv
from esphome.const import (
@@ -43,6 +44,7 @@ from esphome.const import (
CONF_TRIGGER_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE,
CONF_WEB_SERVER,
CONF_WINDOW_SIZE,
DEVICE_CLASS_ABSOLUTE_HUMIDITY,
DEVICE_CLASS_APPARENT_POWER,
@@ -108,12 +110,10 @@ from esphome.const import (
DEVICE_CLASS_WIND_SPEED,
ENTITY_CATEGORY_CONFIG,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
entity_duplicate_validator,
lazy_load_validator,
mqtt_component_class,
queue_entity_register,
setup_device_class,
setup_entity,
@@ -314,14 +314,12 @@ validate_icon = cv.icon
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
_SENSOR_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMPONENT_SCHEMA)
.extend(entity_helpers.ZIGBEE_SENSOR_SCHEMA)
.extend(zigbee.SENSOR_SCHEMA)
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt_component_class("MQTTSensorComponent")
),
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent),
cv.GenerateID(): cv.declare_id(Sensor),
cv.Optional(
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
@@ -361,7 +359,7 @@ _SENSOR_SCHEMA = (
)
_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("sensor"))
_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_sensor"))
_SENSOR_SCHEMA.add_extra(zigbee.validate_sensor)
def sensor_schema(
@@ -981,20 +979,22 @@ async def setup_sensor_core_(var, config):
CORE.add_job(_build_sensor_automations, var, config)
mqtt_ = await entity_helpers.setup_entity_integrations(var, config)
if mqtt_ is not None and (
(expire_after := config.get(CONF_EXPIRE_AFTER, cv.UNDEFINED))
is not cv.UNDEFINED
):
if expire_after is None:
cg.add(mqtt_.disable_expire_after())
else:
cg.add(mqtt_.set_expire_after(expire_after))
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
mqtt_ = cg.new_Pvariable(mqtt_id, var)
await mqtt.register_mqtt_component(mqtt_, config)
if "zigbee" in CORE.loaded_integrations:
from esphome.components import zigbee
if (
expire_after := config.get(CONF_EXPIRE_AFTER, cv.UNDEFINED)
) is not cv.UNDEFINED:
if expire_after is None:
cg.add(mqtt_.disable_expire_after())
else:
cg.add(mqtt_.set_expire_after(expire_after))
await zigbee.setup_sensor(var, config)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
await zigbee.setup_sensor(var, config)
async def register_sensor(var, config):
@@ -20,18 +20,14 @@ void TemplateText::setup() {
// Need std::string for pref_->setup() to fill from flash
std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""};
// For future hash migration: use migrate_entity_preference_() with:
// old_key = get_preference_hash() + extra
// new_key = get_preference_hash_v2() + extra
// See: https://github.com/esphome/backlog/issues/85
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
uint32_t key = this->get_preference_hash();
#pragma GCC diagnostic pop
key += this->traits.get_min_length() << 2;
key += this->traits.get_max_length() << 4;
key += fnv1_hash(this->traits.get_pattern_c_str()) << 6;
this->pref_->setup(key, value);
uint32_t extra = 0;
extra += this->traits.get_min_length() << 2;
extra += this->traits.get_max_length() << 4;
extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6;
// TextSaver::setup() picks the key for the platform and migrates old data once
uint32_t key = this->preference_key_base_() + extra;
uint32_t old_key = this->old_preference_key_base_() + extra;
this->pref_->setup(key, old_key, value);
if (!value.empty())
this->publish_state(value);
}
@@ -14,7 +14,9 @@ class TemplateTextSaverBase {
public:
virtual bool save(const std::string &value) { return true; }
virtual void setup(uint32_t id, std::string &value) {}
/// old_id is the pre-2026.8.0 preference key; data stored under it is moved to id once.
/// See: https://github.com/esphome/backlog/issues/85
virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {}
protected:
ESPPreferenceObject pref_;
@@ -45,11 +47,16 @@ template<uint8_t SZ> class TextSaver : public TemplateTextSaverBase {
// Make the preference object. Fill the provided location with the saved data
// If it is available, else leave it alone
void setup(uint32_t id, std::string &value) override {
this->pref_ = global_preferences->make_preference<uint8_t[SZ + 1]>(id);
void setup(uint32_t id, uint32_t old_id, std::string &value) override {
char temp[SZ + 1];
#ifdef USE_PREFERENCE_KEY_LOOKUP
this->pref_ = global_preferences->make_preference<uint8_t[SZ + 1]>(id);
bool hasdata = migrate_preference(this->pref_, reinterpret_cast<uint8_t *>(temp), SZ + 1, old_id, id);
#else
// Slot-based backends keep the old key; it is only a validity tag on a positional slot
this->pref_ = global_preferences->make_preference<uint8_t[SZ + 1]>(old_id);
bool hasdata = this->pref_.load(&temp);
#endif
if (hasdata) {
size_t len = static_cast<uint8_t>(temp[0]);
+24 -8
View File
@@ -10,11 +10,7 @@ from esphome.components import web_server_base
from esphome.components.logger import request_log_listener
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
import esphome.config_validation as cv
# Re-exported so entity components and external components keep importing
# these from web_server; defined outside this package so entity base
# schemas do not need to import it.
from esphome.const import ( # noqa: F401
from esphome.const import (
CONF_AUTH,
CONF_COMPRESSION,
CONF_CSS_INCLUDE,
@@ -30,8 +26,6 @@ from esphome.const import ( # noqa: F401
CONF_OTA,
CONF_PASSWORD,
CONF_PORT,
CONF_SORTING_GROUP_ID,
CONF_SORTING_WEIGHT,
CONF_TYPE,
CONF_USERNAME,
CONF_VERSION,
@@ -45,7 +39,6 @@ from esphome.const import ( # noqa: F401
PLATFORM_RTL87XX,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import WEBSERVER_SORTING_SCHEMA # noqa: F401
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -56,7 +49,9 @@ AUTO_LOAD = ["json", "web_server_base"]
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
CONF_SORTING_GROUP_ID = "sorting_group_id"
CONF_SORTING_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight"
CONF_ALLOWED_ORIGINS = "allowed_origins"
@@ -230,6 +225,27 @@ sorting_group = {
cv.Optional(CONF_SORTING_WEIGHT): cv.float_,
}
WEBSERVER_SORTING_SCHEMA = cv.Schema(
{
# The per-entity web_server block is cosmetic dashboard ordering —
# mark the whole block advanced; the children inherit via the cascade.
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
{
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer),
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
cv.requires_component("web_server"),
cv.float_,
),
cv.Optional(CONF_SORTING_GROUP_ID): cv.All(
cv.requires_component("web_server"),
cv.use_id(cg.int_),
),
}
)
}
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
+38 -10
View File
@@ -14,12 +14,10 @@ from esphome.components.esp32.const import (
)
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START
from esphome.core import CORE, CoroPriority, coroutine_with_priority, entity_helpers
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
# CONF_ENDPOINT, CONF_MAX_EP_NUMBER, CONF_REPORT, CONF_USE_DEVICE_TYPE and
# REPORT are re-exported for existing consumers of this package's namespace.
from .const import ( # noqa: F401
from .const import (
CONF_ENDPOINT,
CONF_MAX_EP_NUMBER,
CONF_ON_JOIN,
@@ -47,7 +45,12 @@ from .zigbee_esp32 import (
validate_sensor_esp32,
zigbee_require_vfs_select,
)
from .zigbee_zephyr import zephyr_number, zephyr_switch
from .zigbee_zephyr import (
zephyr_binary_sensor,
zephyr_number,
zephyr_sensor,
zephyr_switch,
)
_LOGGER = logging.getLogger(__name__)
@@ -56,11 +59,36 @@ CODEOWNERS = ["@luar123", "@tomaszduda23"]
CONFLICTS_WITH = ["openthread"]
# Defined in esphome.core.entity_helpers so entity base schemas can reference
# them without importing this package; re-exported here for existing consumers.
BASE_SCHEMA = entity_helpers.ZIGBEE_BASE_ENTITY_SCHEMA
BINARY_SENSOR_SCHEMA = entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA
SENSOR_SCHEMA = entity_helpers.ZIGBEE_SENSOR_SCHEMA
def _check_report_deprecation(value: str) -> str:
if str(value).lower() in ("coordinator", "enable"):
_LOGGER.warning(
"Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead."
)
return value
BASE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
_check_report_deprecation,
cv.enum(REPORT, lower=True),
),
cv.Optional(CONF_ENDPOINT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.int_range(1, CONF_MAX_EP_NUMBER),
),
cv.Optional(CONF_USE_DEVICE_TYPE): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.boolean,
),
}
)
BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor)
SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_sensor)
SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch)
NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number)
+13 -12
View File
@@ -1,14 +1,7 @@
from enum import IntEnum
import esphome.codegen as cg
# The entity schema keys and report enum live outside this package so
# entity base schemas can use them without importing it; re-imported here
# so zigbee code keeps its existing import paths.
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
CONF_ENDPOINT,
CONF_REPORT,
CONF_USE_DEVICE_TYPE,
from esphome.const import (
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_ENERGY,
@@ -55,10 +48,6 @@ from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
UNIT_WATT,
UNIT_WATT_HOURS,
)
from esphome.core.entity_helpers import ( # noqa: F401 # pylint: disable=unused-import
ZIGBEE_MAX_EP_NUMBER as CONF_MAX_EP_NUMBER,
ZIGBEE_REPORT as REPORT,
)
zigbee_ns = cg.esphome_ns.namespace("zigbee")
ZigbeeComponent = zigbee_ns.class_("ZigbeeComponent", cg.Component)
@@ -67,10 +56,22 @@ BinaryAttrs = zigbee_ns.struct("BinaryAttrs")
AnalogAttrs = zigbee_ns.struct("AnalogAttrs")
AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput")
report = zigbee_ns.enum("ZigbeeReportT")
REPORT = {
"coordinator": report.ZIGBEE_REPORT_COORDINATOR,
"enable": report.ZIGBEE_REPORT_ENABLE,
"force": report.ZIGBEE_REPORT_FORCE,
"default": report.ZIGBEE_REPORT_DEFAULT,
}
CONF_ENDPOINT = "endpoint"
CONF_MAX_EP_NUMBER = 239
CONF_ON_JOIN = "on_join"
CONF_WIPE_ON_BOOT = "wipe_on_boot"
CONF_REPORT = "report"
CONF_ROUTER = "router"
CONF_POWER_SOURCE = "power_source"
CONF_USE_DEVICE_TYPE = "use_device_type"
POWER_SOURCE = {
"UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN
"MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE
+3 -8
View File
@@ -1,12 +1,7 @@
# These two schema keys live in esphome.components.const so entity base
# schemas can use them without importing this package.
from esphome.const import ( # noqa: F401 # pylint: disable=unused-import
CONF_ZIGBEE_BINARY_SENSOR,
CONF_ZIGBEE_ID,
CONF_ZIGBEE_SENSOR,
)
CONF_MAX_EP_NUMBER_ZEPHYR = 8
CONF_ZIGBEE_ID = "zigbee_id"
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
CONF_ZIGBEE_SWITCH = "zigbee_switch"
CONF_ZIGBEE_NUMBER = "zigbee_number"
CONF_SLEEPY = "sleepy"
@@ -57,6 +57,24 @@ ZigbeeSensor = zigbee_ns.class_("ZigbeeSensor", cg.Component)
ZigbeeSwitch = zigbee_ns.class_("ZigbeeSwitch", cg.Component)
ZigbeeNumber = zigbee_ns.class_("ZigbeeNumber", cg.Component)
zephyr_binary_sensor = cv.Schema(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
cv.OnlyWith(CONF_ZIGBEE_BINARY_SENSOR, ["nrf52", "zigbee"]): cv.declare_id(
ZigbeeBinarySensor
),
}
)
zephyr_sensor = cv.Schema(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
cv.OnlyWith(CONF_ZIGBEE_SENSOR, ["nrf52", "zigbee"]): cv.declare_id(
ZigbeeSensor
),
}
)
zephyr_switch = cv.Schema(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(ZigbeeComponent),
-8
View File
@@ -387,7 +387,6 @@ CONF_ENABLE_PRIVATE_NETWORK_ACCESS = "enable_private_network_access"
CONF_ENABLE_RRM = "enable_rrm"
CONF_ENABLE_TIME = "enable_time"
CONF_ENCRYPTION = "encryption"
CONF_ENDPOINT = "endpoint"
CONF_ENERGY = "energy"
CONF_ENTITY_CATEGORY = "entity_category"
CONF_ENTITY_ID = "entity_id"
@@ -886,7 +885,6 @@ CONF_REFERENCE_VOLTAGE = "reference_voltage"
CONF_REFRESH = "refresh"
CONF_RELABEL = "relabel"
CONF_REPEAT = "repeat"
CONF_REPORT = "report"
CONF_REPOSITORY = "repository"
CONF_RESET = "reset"
CONF_RESET_DURATION = "reset_duration"
@@ -964,8 +962,6 @@ CONF_SLEEP_DURATION = "sleep_duration"
CONF_SLEEP_PIN = "sleep_pin"
CONF_SLEEP_WHEN_DONE = "sleep_when_done"
CONF_SONY = "sony"
CONF_SORTING_GROUP_ID = "sorting_group_id"
CONF_SORTING_WEIGHT = "sorting_weight"
CONF_SOURCE = "source"
CONF_SOURCE_ID = "source_id"
CONF_SPEAKER = "speaker"
@@ -1097,7 +1093,6 @@ CONF_UPDATE_ON_BOOT = "update_on_boot"
CONF_URL = "url"
CONF_USE_ABBREVIATIONS = "use_abbreviations"
CONF_USE_ADDRESS = "use_address"
CONF_USE_DEVICE_TYPE = "use_device_type"
CONF_USE_DMA = "use_dma"
CONF_USE_FAHRENHEIT = "use_fahrenheit"
CONF_USERNAME = "username"
@@ -1149,9 +1144,6 @@ CONF_Y = "y"
CONF_Y_GRID = "y_grid"
CONF_YEAR = "year"
CONF_ZERO = "zero"
CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
CONF_ZIGBEE_ID = "zigbee_id"
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
TYPE_GIT = "git"
TYPE_LOCAL = "local"
+4 -4
View File
@@ -120,8 +120,8 @@ class Application {
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
void register_##singular(type *obj) { this->plural##_.push_back(obj); } \
void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \
obj->configure_entity_(name, object_id_hash, entity_fields); \
void register_##singular(type *obj, const char *name, uint32_t entity_key, uint32_t entity_fields) { \
obj->configure_entity_(name, entity_key, entity_fields); \
this->plural##_.push_back(obj); \
}
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
@@ -329,7 +329,7 @@ class Application {
#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \
entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \
for (auto *obj : this->entities_member##_) { \
if (obj->get_object_id_hash() == key && obj->get_device_id() == device_id && \
if (obj->get_entity_key() == key && obj->get_device_id() == device_id && \
(include_internal || !obj->is_internal())) \
return obj; \
} \
@@ -340,7 +340,7 @@ class Application {
#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \
entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \
for (auto *obj : this->entities_member##_) { \
if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \
if (obj->get_entity_key() == key && (include_internal || !obj->is_internal())) \
return obj; \
} \
return nullptr; \
+32 -20
View File
@@ -8,7 +8,7 @@ namespace esphome {
static const char *const TAG = "entity_base";
void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) {
void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) {
this->name_ = StringRef(name);
if (this->name_.empty()) {
#ifdef USE_DEVICES
@@ -30,15 +30,15 @@ void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, ui
}
}
this->flags_.has_own_name = false;
// Dynamic name - must calculate hash at runtime
this->calc_object_id_();
// Dynamic name - must calculate key at runtime
this->calc_entity_key_();
} else {
this->flags_.has_own_name = true;
// Static name - use pre-computed hash if provided
if (object_id_hash != 0) {
this->object_id_hash_ = object_id_hash;
// Static name - use pre-computed key if provided
if (entity_key != 0) {
this->entity_key_ = entity_key;
} else {
this->calc_object_id_();
this->calc_entity_key_();
}
}
// Unpack entity string table indices and flags from entity_fields.
@@ -147,9 +147,15 @@ std::string EntityBase::get_icon() const {
}
#endif // !USE_ESP8266
// Calculate Object ID Hash directly from name using snake_case + sanitize
void EntityBase::calc_object_id_() {
this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size());
// Calculate the entity key directly from the raw name (no transformations)
void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); }
// Reconstruct the OLD (pre-2026.8.0) object_id-based hash for preference key compatibility.
// Named entities historically used the hash pre-computed by Python code generation, which
// sanitized per UTF-8 code point; entities without their own name computed the hash at
// runtime per byte. See https://github.com/esphome/backlog/issues/85
uint32_t EntityBase::calc_old_object_id_hash_() const {
return fnv1_hash_object_id(this->name_.c_str(), this->name_.size(), this->flags_.has_own_name);
}
size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const {
@@ -167,16 +173,22 @@ StringRef EntityBase::get_object_id_to(std::span<char, OBJECT_ID_MAX_LEN> buf) c
}
ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) {
// The key hashes the sanitized object_id, so multiple entity names can collide on one
// key and overwrite each other's stored preferences ("Living Room" and "living_room",
// or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name
// fix this, but they change the entity key API clients track, which the Home Assistant
// esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
uint32_t key = this->get_preference_hash() ^ version;
#pragma GCC diagnostic pop
return global_preferences->make_preference(size, key);
// The old key hashed the sanitized object_id, so multiple entity names could collide on
// one key and overwrite each other's stored preferences; the new key hashes the raw name.
// See: https://github.com/esphome/backlog/issues/85
uint32_t old_key = this->old_preference_key_base_() ^ version;
#ifdef USE_PREFERENCE_KEY_LOOKUP
uint32_t new_key = this->preference_key_base_() ^ version;
auto pref = global_preferences->make_preference(size, new_key);
// All in-tree entity preferences fit the stack buffer, so migration never hits the heap
SmallBufferWithHeapFallback<64> buffer(size);
migrate_preference(pref, buffer.get(), size, old_key, new_key);
return pref;
#else
// Slot-based backends keep the old key: it is only a validity tag on a positional slot,
// so collisions cannot corrupt data there and keeping it preserves stored state.
return global_preferences->make_preference(size, old_key);
#endif
}
#ifdef USE_ENTITY_ICON
+40 -36
View File
@@ -73,8 +73,17 @@ class EntityBase {
// Get whether this Entity has its own name or it should use the device friendly_name.
bool has_own_name() const { return this->flags_.has_own_name; }
// Get the unique Object ID of this Entity
uint32_t get_object_id_hash() const { return this->object_id_hash_; }
// Get the unique key of this Entity: FNV-1 hash of the raw entity name.
// This is the key sent to API clients and used to route entity state.
uint32_t get_entity_key() const { return this->entity_key_; }
/// Returns the LEGACY object_id hash, unchanged from previous releases, so existing
/// callers keep getting stable values (for example preference keys). This is no longer
/// the key sent to API clients; that is get_entity_key().
ESPDEPRECATED("Use get_entity_key() for the entity key sent to API clients, or "
"make_entity_preference<T>() for preference storage. Will be removed in 2027.1.0.",
"2026.8.0")
uint32_t get_object_id_hash() const { return this->calc_old_object_id_hash_(); }
/// Get object_id with zero heap allocation
/// For static case: returns StringRef to internal storage (buffer unused)
@@ -181,40 +190,24 @@ class EntityBase {
// Set has_state - for components that need to manually set this
void set_has_state(bool state) { this->flags_.has_state = state; }
/**
* @brief Get a unique hash for storing preferences/settings for this entity.
*
* This method returns a hash that uniquely identifies the entity for the purpose of
* storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(),
* this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness
* across multiple devices that may have entities with the same object_id.
*
* Use this method when storing or retrieving preferences/settings that should be unique
* per device-entity pair. Use get_object_id_hash() when you need a hash that identifies
* the entity regardless of the device it belongs to.
*
* For backward compatibility, if device_id is 0 (the main device), the hash is unchanged
* from previous versions, so existing single-device configurations will continue to work.
*
* @return uint32_t The unique hash for preferences, including device_id if available.
* @deprecated Use make_entity_preference<T>() instead, or preferences won't be migrated.
* See https://github.com/esphome/backlog/issues/85
*/
ESPDEPRECATED("Use make_entity_preference<T>() instead, or preferences won't be migrated. "
"See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.",
"2026.7.0")
uint32_t get_preference_hash() {
/// Get this entity's device id, or 0 when devices are not compiled in (main device).
uint32_t get_device_id_or_zero() const {
#ifdef USE_DEVICES
// Combine object_id_hash with device_id to ensure uniqueness across devices
// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash
// This ensures backward compatibility for existing single-device configurations
return this->get_object_id_hash() ^ this->get_device_id();
return this->get_device_id();
#else
// Without devices, just use object_id_hash as before
return this->get_object_id_hash();
return 0;
#endif
}
/// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id.
/// Intentionally keeps the old algorithm so external callers that store preferences under
/// this key keep stable keys; make_entity_preference() migrates to the new raw-name key,
/// this method never will.
ESPDEPRECATED("Use make_entity_preference<T>() instead, or preferences won't be migrated. "
"See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.",
"2026.8.0")
uint32_t get_preference_hash() { return this->old_preference_key_base_(); }
/// Create a preference object for storing this entity's state/settings.
/// @tparam T The type of data to store (must be trivially copyable)
/// @param version Optional version hash XORed with preference key (change when struct layout changes)
@@ -230,9 +223,9 @@ class EntityBase {
// before push_back, so codegen can emit a single combined call per entity.
friend class Application;
/// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags.
/// Combined entity setup from codegen: set name, entity key, entity string indices, and flags.
/// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above.
void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields);
void configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields);
#ifdef USE_DEVICES
// Codegen-only setter — only accessible from setup() via friend declaration.
@@ -240,13 +233,24 @@ class EntityBase {
#endif
/// Non-template helper for make_entity_preference() to avoid code bloat.
/// When the preference hash algorithm changes, migration logic goes here.
/// Migrates preferences from the old sanitized-object_id key to the raw-name key
/// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85
ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version);
void calc_object_id_();
void calc_entity_key_();
/// Reconstruct the OLD (pre-2026.8.0) sanitized-object_id hash for preference keys.
uint32_t calc_old_object_id_hash_() const;
/// Preference key base for this entity: raw-name entity key XOR device_id.
uint32_t preference_key_base_() const { return this->entity_key_ ^ this->get_device_id_or_zero(); }
/// Legacy preference key base: sanitized-object_id hash XOR device_id.
/// Note: device_id is 0 for the main device, so XORing with 0 preserves the original hash.
uint32_t old_preference_key_base_() const { return this->calc_old_object_id_hash_() ^ this->get_device_id_or_zero(); }
StringRef name_;
uint32_t object_id_hash_{};
uint32_t entity_key_{};
#ifdef USE_DEVICES
Device *device_{};
#endif
+112 -249
View File
@@ -1,8 +1,6 @@
from collections.abc import Callable
from dataclasses import dataclass, field
import functools
from importlib import import_module
from importlib.util import find_spec
import logging
import esphome.codegen as cg
@@ -11,23 +9,12 @@ from esphome.const import (
CONF_DEVICE_CLASS,
CONF_DEVICE_ID,
CONF_DISABLED_BY_DEFAULT,
CONF_ENDPOINT,
CONF_ENTITY_CATEGORY,
CONF_ICON,
CONF_ID,
CONF_INTERNAL,
CONF_MQTT_ID,
CONF_NAME,
CONF_REPORT,
CONF_SORTING_GROUP_ID,
CONF_SORTING_WEIGHT,
CONF_UNIT_OF_MEASUREMENT,
CONF_USE_DEVICE_TYPE,
CONF_WEB_SERVER,
CONF_WEB_SERVER_ID,
CONF_ZIGBEE_BINARY_SENSOR,
CONF_ZIGBEE_ID,
CONF_ZIGBEE_SENSOR,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.config import (
@@ -35,28 +22,89 @@ from esphome.core.config import (
ICON_MAX_LENGTH,
UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import MockObj, MockObjClass, RawStatement, add, get_variable
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
from esphome.cpp_types import App
import esphome.final_validate as fv
from esphome.helpers import (
cpp_string_escape,
fnv1_hash,
fnv1_hash_object_id,
sanitize,
snake_case,
)
from esphome.helpers import cpp_string_escape, fnv1_hash_name, sanitize, snake_case
from esphome.types import ConfigType, EntityMetadata
_LOGGER = logging.getLogger(__name__)
DOMAIN = "entity_string_pool"
_OBJECT_ID_DOMAIN = "entity_object_ids"
@dataclass
class ObjectIdEntity:
"""An entity tracked by the sanitized object_id its name resolves to."""
name: str
platform: str
config: ConfigType
def _get_object_id_registry() -> dict[tuple[str, str, str], list[ObjectIdEntity]]:
"""(device_id, platform, sanitized object_id) -> entities resolving to it."""
return CORE.data.setdefault(_OBJECT_ID_DOMAIN, {})
def validate_no_object_id_conflicts(
reason: str,
conflict_filter: Callable[[list[ObjectIdEntity], ConfigType], bool] | None = None,
) -> Callable[[ConfigType], ConfigType]:
"""Create a final-validate step that rejects entities with colliding object_ids.
Entity keys are hashed from the raw name, so names that only differ in characters
lost during sanitizing (for example two UTF-8 names) validate fine in general.
Components that still address entities by the sanitized object_id string must
reject those configs until they are migrated to raw names.
Args:
reason: One sentence stating what the component builds from the object_id,
e.g. "mqtt builds default topics from the entity object_id"
conflict_filter: Optional predicate receiving the colliding entities and the
component config; return False when the component is not affected
Returns:
A validator function for use as (or within) FINAL_VALIDATE_SCHEMA
"""
def validator(config: ConfigType) -> ConfigType:
# Skip in testing_mode, which is used for grouped component testing
if CORE.testing_mode:
return config
conflicts = {
key: entities
for key, entities in _get_object_id_registry().items()
if len(entities) > 1
and (conflict_filter is None or conflict_filter(entities, config))
}
if not conflicts:
return config
lines = [f"{reason}, so these entities would conflict:"]
lines.extend(
f" - {platform} entities "
+ ", ".join(f"'{e.name}'" for e in entities)
+ (f" on device '{device_id}'" if device_id else "")
+ f" share the object_id '{object_id}'"
for (device_id, platform, object_id), entities in conflicts.items()
)
lines.append(
"To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B') "
"to distinguish the names"
)
raise cv.Invalid("\n".join(lines))
return validator
# Private config keys for storing registered string indices
_KEY_DC_IDX = "_entity_dc_idx"
_KEY_UOM_IDX = "_entity_uom_idx"
_KEY_ICON_IDX = "_entity_icon_idx"
_KEY_ENTITY_NAME = "_entity_name"
_KEY_OBJECT_ID_HASH = "_entity_object_id_hash"
_KEY_ENTITY_KEY = "_entity_key"
# Bit layout for entity_fields in configure_entity_().
# Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h
@@ -319,7 +367,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
standalone ``var->configure_entity_(name, hash, packed)``.
"""
entity_name = config[_KEY_ENTITY_NAME]
object_id_hash = config[_KEY_OBJECT_ID_HASH]
entity_key = config[_KEY_ENTITY_KEY]
dc_idx = config.get(_KEY_DC_IDX, 0)
uom_idx = config.get(_KEY_UOM_IDX, 0)
icon_idx = config.get(_KEY_ICON_IDX, 0)
@@ -339,57 +387,30 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
register_method = config.get(_KEY_REGISTER_METHOD)
if register_method is not None:
expr = getattr(App, f"register_{register_method}")(
var, entity_name, object_id_hash, packed
var, entity_name, entity_key, packed
)
else:
expr = var.configure_entity_(entity_name, object_id_hash, packed)
expr = var.configure_entity_(entity_name, entity_key, packed)
if comment:
add(RawStatement(f"{expr}; // {comment}"))
else:
add(expr)
def get_base_entity_object_id(
def get_base_entity_name(
name: str, friendly_name: str | None, device_name: str | None = None
) -> str:
"""Calculate the base object ID for an entity that will be set via set_object_id().
"""Return the base name whose hash becomes this entity's key on the device.
This function calculates what object_id_c_str_ should be set to in C++.
Follows the name selection in C++ EntityBase::configure_entity_() (entity_base.cpp):
entity name, then sub-device name, then friendly name, then the device name.
The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as:
- If !has_own_name && is_name_add_mac_suffix_enabled():
return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic
- Else:
return object_id_c_str_ ?? "" // What we set via set_object_id()
Since we're calculating what to pass to set_object_id(), we always need to
generate the object_id the same way, regardless of name_add_mac_suffix setting.
Args:
name: The entity name (empty string if no name)
friendly_name: The friendly name from CORE.friendly_name
device_name: The device name if entity is on a sub-device
Returns:
The base object ID to use for duplicate checking and to pass to set_object_id()
This is a config-time approximation for duplicate checking: when
name_add_mac_suffix is enabled the device appends the MAC suffix at runtime,
which is unknown here and identical for every entity on the device, so
ignoring it cannot change whether two entities collide with each other.
"""
if name:
# Entity has its own name (has_own_name will be true)
base_str = name
elif device_name:
# Entity has empty name and is on a sub-device
# C++ EntityBase::set_name() uses device->get_name() when device is set
base_str = device_name
elif friendly_name:
# Entity has empty name (has_own_name will be false)
# C++ uses App.get_friendly_name() which returns friendly_name or device name
base_str = friendly_name
else:
# Fallback to device name
base_str = CORE.name
return sanitize(snake_case(base_str))
return name or device_name or friendly_name or CORE.name
def setup_entity(var_or_platform, config=None, platform=None):
@@ -448,15 +469,15 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) ->
device: MockObj = await get_variable(device_id_obj)
add(var.set_device_(device))
# Pre-compute entity name and object_id hash for configure_entity_()
# Pre-compute entity name and entity key for configure_entity_()
# which is emitted later by finalize_entity_strings().
# For named entities: pre-compute hash from entity name
# For empty-name entities: pass 0, C++ calculates hash at runtime from
# device name, friendly_name, or app name (bug-for-bug compatibility)
# For named entities: pre-compute the key from the raw entity name
# For empty-name entities: pass 0, C++ calculates the key at runtime from
# device name, friendly_name, or app name
entity_name = config[CONF_NAME]
object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0
entity_key = fnv1_hash_name(entity_name) if entity_name else 0
config[_KEY_ENTITY_NAME] = entity_name
config[_KEY_OBJECT_ID_HASH] = object_id_hash
config[_KEY_ENTITY_KEY] = entity_key
# Store flags for packing into configure_entity_()
config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT])
if CONF_INTERNAL in config:
@@ -569,16 +590,13 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
# Use the device ID string directly for uniqueness
device_id = device_id_obj.id
# Calculate what object_id will actually be used
# This handles empty names correctly by using device/friendly names
name_key = get_base_entity_object_id(
entity_name, CORE.friendly_name, device_name
)
# Hash the same raw name the device hashes into the entity key at runtime.
# This handles empty names correctly by using device/friendly names.
base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name)
name_hash = fnv1_hash_name(base_name)
# Check for duplicates by the FNV-1 hash of the object_id, which is the entity
# key that routes state to API clients. This rejects names that sanitize to the
# same object_id, and also two different object_ids whose 32-bit hashes collide.
name_hash = fnv1_hash(name_key)
# Check for duplicates: two entities on the same device and platform must not
# share an entity key, since the key is what routes state to API clients
unique_key = (device_id, platform, name_hash)
if unique_key in CORE.unique_ids:
# Get the existing entity metadata
@@ -603,26 +621,14 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
if existing_component != "unknown":
conflict_msg += f" from component '{existing_component}'"
# Distinguish names that sanitize to the same object_id from a genuine
# 32-bit hash collision between two different object_ids
# Different names can only clash here through a genuine hash collision
collision_msg = ""
if entity_name != existing_name:
existing_object_id = get_base_entity_object_id(
existing_name, CORE.friendly_name, existing_device or None
collision_msg = (
f"\n The names '{entity_name}' and '{existing_name}' produce the"
f"\n same entity key hash ({name_hash:#010x})."
"\n To fix: Rename one of the entities"
)
if existing_object_id == name_key:
collision_msg = (
f"\n Original names: '{entity_name}' and '{existing_name}'"
f"\n Both convert to ASCII ID: '{name_key}'"
"\n To fix: Add unique ASCII characters (e.g., '1', '2', or 'A', 'B')"
"\n to distinguish them"
)
else:
collision_msg = (
f"\n The object_ids '{name_key}' and '{existing_object_id}'"
f"\n produce the same entity key hash ({name_hash:#010x})."
"\n To fix: Rename one of the entities"
)
# Skip duplicate entity name validation when testing_mode is enabled
# This flag is used for grouped component testing
@@ -634,6 +640,19 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
f"{collision_msg}"
)
# Components that still address entities by the sanitized object_id reject
# colliding names in final validation via validate_no_object_id_conflicts(),
# so track every entity by the object_id its name resolves to. Scoped per
# device and platform to match the strictness configs had before entity keys
# moved to raw names: same-named entities on different sub-devices were
# already accepted then, internal entities were already skipped (above), and
# overlaps between platforms that share an MQTT component type (sensor and
# text_sensor both publish under "sensor") were already possible.
object_id = sanitize(snake_case(base_name))
_get_object_id_registry().setdefault(
(device_id, platform, object_id), []
).append(ObjectIdEntity(base_name, platform, config))
# Store metadata about this entity
entity_metadata: EntityMetadata = {
"name": entity_name,
@@ -648,159 +667,3 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
return config
return validator
# ---------------------------------------------------------------------------
# Cross-integration entity schema fragments
# ---------------------------------------------------------------------------
#
# Entity base schemas offer mqtt/web_server/zigbee options, but importing
# those packages pulls their full dependency chains (mqtt and zigbee both
# import the esp32 package) into every entity component import. The
# fragments below are built from cheap primitives instead: MockObjClass
# identity is string-based, so the class handles here are interchangeable
# with the ones the integrations declare, and every key is guarded by
# ``cv.requires_component``/``cv.OnlyWith``, which consult
# ``CORE.loaded_integrations`` at validation time without importing. The
# owning integrations re-export the shared schema names and key strings so
# each stays defined once.
_WebServer = cg.esphome_ns.namespace("web_server").class_(
"WebServer", cg.Component, cg.Controller
)
WEBSERVER_SORTING_SCHEMA = cv.Schema(
{
# The per-entity web_server block is cosmetic dashboard ordering —
# mark the whole block advanced; the children inherit via the cascade.
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
{
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(_WebServer),
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
cv.requires_component("web_server"),
cv.float_,
),
cv.Optional(CONF_SORTING_GROUP_ID): cv.All(
cv.requires_component("web_server"),
cv.use_id(cg.int_),
),
}
)
}
)
_mqtt_ns = cg.esphome_ns.namespace("mqtt")
_MQTTComponent = _mqtt_ns.class_("MQTTComponent", cg.Component)
def mqtt_component_class(name: str) -> MockObjClass:
"""Handle for a per-entity mqtt::<name> companion class."""
return _mqtt_ns.class_(name, _MQTTComponent)
ZIGBEE_MAX_EP_NUMBER = 239
_zigbee_ns = cg.esphome_ns.namespace("zigbee")
_ZigbeeComponent = _zigbee_ns.class_("ZigbeeComponent", cg.Component)
_zigbee_report = _zigbee_ns.enum("ZigbeeReportT")
ZIGBEE_REPORT = {
"coordinator": _zigbee_report.ZIGBEE_REPORT_COORDINATOR,
"enable": _zigbee_report.ZIGBEE_REPORT_ENABLE,
"force": _zigbee_report.ZIGBEE_REPORT_FORCE,
"default": _zigbee_report.ZIGBEE_REPORT_DEFAULT,
}
def _check_report_deprecation(value: str) -> str:
if str(value).lower() in ("coordinator", "enable"):
_LOGGER.warning(
"Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead."
)
return value
ZIGBEE_BASE_ENTITY_SCHEMA = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
_check_report_deprecation,
cv.enum(ZIGBEE_REPORT, lower=True),
),
cv.Optional(CONF_ENDPOINT): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.int_range(1, ZIGBEE_MAX_EP_NUMBER),
),
cv.Optional(CONF_USE_DEVICE_TYPE): cv.All(
cv.requires_component("zigbee"),
cv.requires_component("esp32"),
cv.boolean,
),
}
)
# Entity platform -> (config key, zigbee C++ class). A unit test checks each
# class against the owning declaration in zigbee_zephyr.
_ZIGBEE_ENTITY_CLASSES = {
"binary_sensor": (CONF_ZIGBEE_BINARY_SENSOR, "ZigbeeBinarySensor"),
"sensor": (CONF_ZIGBEE_SENSOR, "ZigbeeSensor"),
}
def _zigbee_entity_schema(platform: str) -> cv.Schema:
conf_key, class_name = _ZIGBEE_ENTITY_CLASSES[platform]
return ZIGBEE_BASE_ENTITY_SCHEMA.extend(
{
cv.OnlyWith(CONF_ZIGBEE_ID, ["nrf52", "zigbee"]): cv.use_id(
_ZigbeeComponent
),
cv.OnlyWith(conf_key, ["nrf52", "zigbee"]): cv.declare_id(
_zigbee_ns.class_(class_name, cg.Component)
),
}
)
ZIGBEE_BINARY_SENSOR_SCHEMA = _zigbee_entity_schema("binary_sensor")
ZIGBEE_SENSOR_SCHEMA = _zigbee_entity_schema("sensor")
def lazy_load_validator(
component: str, name: str
) -> Callable[[ConfigType], ConfigType]:
"""Schema extra delegating to ``components.<component>.<name>`` when loaded."""
if find_spec(f"esphome.components.{component}") is None:
raise ValueError(f"No such component {component!r}")
def validator(config: ConfigType) -> ConfigType:
if component not in CORE.loaded_integrations:
return config
module = import_module(f"esphome.components.{component}")
if (delegate := getattr(module, name, None)) is None:
raise ValueError(f"{component} has no validator {name!r}")
return delegate(config)
return validator
async def setup_entity_integrations(var: MockObj, config: ConfigType) -> MockObj | None:
"""Register the mqtt companion and web_server entry for an entity.
Imports the integrations lazily; returns the mqtt companion (or None)
so callers can apply integration specific options to it.
"""
mqtt_ = None
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
from esphome.components import mqtt
mqtt_ = cg.new_Pvariable(mqtt_id, var)
await mqtt.register_mqtt_component(mqtt_, config)
if web_server_config := config.get(CONF_WEB_SERVER):
from esphome.components import web_server
await web_server.add_entity_config(var, web_server_config)
return mqtt_
+25 -4
View File
@@ -809,6 +809,19 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL;
/// FNV-1 32-bit prime
constexpr uint32_t FNV1_PRIME = 16777619UL;
/// Calculate a FNV-1 hash over raw bytes with an explicit length. Unlike fnv1_hash(const char *),
/// each byte is hashed as an unsigned value, so results are platform-independent for bytes >= 0x80.
/// IMPORTANT: Must match Python fnv1_hash_name() in esphome/helpers.py, which hashes the UTF-8
/// encoded bytes of the name. Used to compute entity keys from raw names.
inline uint32_t fnv1_hash_bytes(const char *str, size_t len) {
uint32_t hash = FNV1_OFFSET_BASIS;
for (size_t i = 0; i < len; i++) {
hash *= FNV1_PRIME;
hash ^= static_cast<uint8_t>(str[i]);
}
return hash;
}
/// Extend a FNV-1 hash with an integer (hashes each byte).
template<std::integral T> constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) {
using UnsignedT = std::make_unsigned_t<T>;
@@ -1013,12 +1026,20 @@ template<size_t N> inline char *str_sanitize_to(char (&buffer)[N], const char *s
// str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0
/// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations.
/// This computes object_id hashes directly from names without creating an intermediate buffer.
/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py.
/// If you modify this function, update the Python version and tests in both places.
inline uint32_t fnv1_hash_object_id(const char *str, size_t len) {
/// This is the LEGACY entity hash, kept only to reconstruct preference keys that existing
/// devices already have stored; see https://github.com/esphome/backlog/issues/85.
/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character
/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py,
/// which produced the hash for named entities. The per-byte form (default) matches the old
/// runtime hash for entities without their own name. Do not change either behavior.
/// Known limitation: Python's lower() is Unicode aware, so the rare code points it maps to a
/// different number of characters or to ASCII (e.g. 'İ', the Kelvin sign) reconstruct wrong;
/// such names skip migration once and fall back to their defaults.
inline uint32_t fnv1_hash_object_id(const char *str, size_t len, bool per_code_point = false) {
uint32_t hash = FNV1_OFFSET_BASIS;
for (size_t i = 0; i < len; i++) {
if (per_code_point && (static_cast<uint8_t>(str[i]) & 0xC0) == 0x80)
continue; // UTF-8 continuation byte, already counted via its lead byte
hash *= FNV1_PRIME;
// Apply snake_case (space->underscore, uppercase->lowercase) then sanitize
hash ^= static_cast<uint8_t>(to_sanitized_char(to_snake_case_char(str[i])));
+7 -7
View File
@@ -24,10 +24,9 @@
#endif
// Key-lookup preference backends find stored data by key; their platforms add the
// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads
// of stored data by key (the primitive preference key migrations need). Slot-based
// backends (ESP8266, RP2040) instead allocate a storage slot for every
// make_preference() call and use the key only as a validity tag on that slot;
// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables preference key
// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for
// every make_preference() call and use the key only as a validity tag on that slot;
// migration is not possible there, and key collisions cannot corrupt data.
namespace esphome {
@@ -105,9 +104,10 @@ concept PreferencesContract = requires(T prefs, size_t len, uint32_t type, bool
};
// Key-lookup platforms additionally provide load_from_key(), a one-shot read
// of a stored preference by key; see the key-lookup note at the top of this
// file. Not part of PreferencesContract, so it is asserted in preferences.h
// only where USE_PREFERENCE_KEY_LOOKUP is set.
// of a stored preference by key that migrate_preference() relies on; see the
// key-lookup note at the top of this file. Not part of PreferencesContract,
// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP
// is set.
template<typename T>
concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) {
{ prefs.load_from_key(type, data, len) } -> std::same_as<bool>;
+25
View File
@@ -0,0 +1,25 @@
#include "esphome/core/preferences.h"
#include "esphome/core/log.h"
#include <cinttypes>
namespace esphome {
#ifdef USE_PREFERENCE_KEY_LOOKUP
static const char *const TAG = "preferences";
bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key,
uint32_t new_key) {
if (new_pref.load(scratch, size))
return true; // Current data present - never overwrite newer data with the old copy
// One-shot read by key: no backend is allocated for the old key, so boots with
// nothing to migrate (for example fresh installs) cost no heap
if (old_key == new_key || !global_preferences->load_from_key(old_key, scratch, size))
return false; // No data stored under the old key, nothing to migrate
if (!new_pref.save(scratch, size)) {
ESP_LOGW(TAG, "Pref migration %" PRIx32 " -> %" PRIx32 " failed", old_key, new_key);
}
return true;
}
#endif // USE_PREFERENCE_KEY_LOOKUP
} // namespace esphome
+12
View File
@@ -56,5 +56,17 @@ namespace esphome {
static_assert(PreferencesKeyLookupContract<ESPPreferences>,
"This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide "
"load_from_key() (esphome/core/preference_backend.h)");
/// Copy preference data stored under old_key into new_pref (created for new_key) if the keys
/// differ and new_pref has no data yet. scratch must hold at least size bytes.
/// Returns true when scratch holds the entity's current data (loaded or just migrated).
/// The old entry is intentionally left in place so a firmware downgrade still finds its data.
/// If saving under the new key fails, callers that consume scratch (like TextSaver) still get
/// valid data for this boot, callers that reload from the preference fall back to their
/// defaults, and the migration simply runs again on the next boot.
/// Only available on key-lookup preference backends; slot-based backends keep their old
/// keys instead. See: https://github.com/esphome/backlog/issues/85
bool migrate_preference(ESPPreferenceObject &new_pref, uint8_t *scratch, size_t size, uint32_t old_key,
uint32_t new_key);
} // namespace esphome
#endif // USE_PREFERENCE_KEY_LOOKUP
+10 -5
View File
@@ -91,8 +91,13 @@ def fnv1a_32bit_hash(string: str) -> int:
def fnv1_hash_object_id(name: str) -> int:
"""Compute FNV-1 hash of name with snake_case + sanitize transformations.
IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h.
If you modify this function, update the C++ version and tests in both places.
IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h
with per_code_point set. This is the OLD entity hash; it computes preference
keys that existing devices already have stored (see
https://github.com/esphome/backlog/issues/85) and is also still used for live
keys derived from config IDs (see the motion component's calibration key).
Note: lower() here is Unicode aware while the C++ reconstruction is not; see
the known limitation note on the C++ function.
"""
return fnv1_hash(sanitize(snake_case(name)))
@@ -100,9 +105,9 @@ def fnv1_hash_object_id(name: str) -> int:
def fnv1_hash_name(name: str) -> int:
"""Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations).
2026.8 beta firmware stored preferences under keys derived from this hash;
a future key migration must reconstruct those keys to recover that data
(see https://github.com/esphome/backlog/issues/85).
IMPORTANT: Must produce same result as C++ fnv1_hash_bytes() in helpers.h,
which hashes the name bytes as stored on the device.
Used for pre-computing entity keys at code generation time.
"""
return _fnv1_hash(name.encode("utf-8"))
+38 -25
View File
@@ -269,9 +269,10 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
# If `domain` is the legacy name of a renamed component, redirect to the
# canonical module so the rest of the loader (and every caller of
# `get_component(legacy)`) transparently sees the new component.
alias_meta = get_alias_metadata().get(domain)
if alias_meta is not None:
manif = _lookup_module(alias_meta.canonical, exception)
alias_map = _get_alias_map()
if domain in alias_map:
canonical = alias_map[domain]
manif = _lookup_module(canonical, exception)
if manif is not None:
_COMPONENT_CACHE[domain] = manif
return manif
@@ -328,10 +329,8 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non
# ---------------------------------------------------------------------------
#
# A component can declare ``ALIASES = ["legacy_name"]`` (and optionally
# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``, then run
# ``script/build_alias_registry.py`` to regenerate
# ``esphome/component_aliases.py`` (CI and a unit test fail if the registry
# is stale). Two integrations are then wired up automatically:
# ``ALIAS_REMOVAL_VERSION = "YYYY.M.0"``) in its ``__init__.py``. Two
# integrations are then wired up automatically:
#
# 1. **Python imports** — a ``sys.meta_path`` finder (``_AliasFinder``)
# intercepts ``esphome.components.<legacy>``/``...<legacy>.<sub>``
@@ -345,13 +344,13 @@ def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> Non
# dependency checks, schema validation and codegen all see only the
# canonical name.
#
# Both lookups read the checked-in registry in ``esphome.component_aliases``
# (generated by ``script/build_alias_registry.py``, verified in CI), so no
# component-directory scan happens at runtime. ``_build_alias_map`` below is
# the generator's scan implementation; it **AST-parses** each component's
# ``__init__.py`` rather than importing it.
# Both lookups are populated by ``_build_alias_map``, which **AST-parses**
# every component's ``__init__.py`` rather than importing it. That keeps the
# cost low: scanning ~400 components on disk takes ~5 ms instead of the
# multi-second cost of executing every component's import side-effects.
_ALIAS_MAP_CACHE: dict[str, str] | None = None
_ALIAS_META_CACHE: dict[str, "AliasMeta"] | None = None
@@ -368,17 +367,31 @@ class AliasMeta:
removal_version: str | None
def get_alias_metadata() -> dict[str, AliasMeta]:
"""Return the legacy-name → :class:`AliasMeta` map, built lazily from
the generated registry."""
global _ALIAS_META_CACHE # noqa: PLW0603
if _ALIAS_META_CACHE is None:
from esphome.component_aliases import COMPONENT_ALIASES
def _ensure_alias_caches() -> None:
"""Populate both alias caches from a single directory scan.
_ALIAS_META_CACHE = {
alias: AliasMeta(canonical=canonical, removal_version=removal_version)
for alias, (canonical, removal_version) in COMPONENT_ALIASES.items()
}
``_build_alias_map`` returns both maps together, so building them in one
shot avoids scanning every component's ``__init__.py`` twice when a run
needs both the canonical map (loader) and the metadata map (config
pre-pass).
"""
global _ALIAS_MAP_CACHE, _ALIAS_META_CACHE
if _ALIAS_MAP_CACHE is None or _ALIAS_META_CACHE is None:
_ALIAS_MAP_CACHE, _ALIAS_META_CACHE = _build_alias_map()
def _get_alias_map() -> dict[str, str]:
"""Return the legacy-name → canonical-name map, building it lazily."""
_ensure_alias_caches()
return _ALIAS_MAP_CACHE
def get_alias_metadata() -> dict[str, AliasMeta]:
"""Return the legacy-name → :class:`AliasMeta` map (cached).
Used by the YAML pre-pass to format a per-alias deprecation warning.
"""
_ensure_alias_caches()
return _ALIAS_META_CACHE
@@ -524,11 +537,11 @@ class _AliasFinder(importlib.abc.MetaPathFinder):
# least three parts, so ``parts[2]`` (the domain) always exists.
parts = fullname.split(".")
domain = parts[2]
alias_meta = get_alias_metadata().get(domain)
if alias_meta is None:
alias_map = _get_alias_map()
if domain not in alias_map:
return None
parts[2] = alias_meta.canonical
parts[2] = alias_map[domain]
canonical_fullname = ".".join(parts)
try:
canonical_module = importlib.import_module(canonical_fullname)
+3 -3
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.1.19
esptool==5.3.1
click==8.3.3
aioesphomeapi==45.10.2
aioesphomeapi==45.10.1
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.150.0
puremagic==2.2.0
@@ -23,11 +23,11 @@ pillow==12.3.0
resvg-py==0.3.4
freetype-py==2.5.1
jinja2==3.1.6
bleak==3.0.2
bleak==2.1.1
smpclient==7.2.0
requests==2.34.2
py7zr==1.1.3
platformdirs==4.11.2 # native esp-idf toolchain global cache dir
platformdirs==4.11.1 # native esp-idf toolchain global cache dir
filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
+2 -2
View File
@@ -1,8 +1,8 @@
pylint==4.0.7
pylint==4.0.6
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.16.2 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
prek==0.4.13 # also change in .github/workflows/ci.yml when updating
prek==0.4.12 # also change in .github/workflows/ci.yml when updating
# Unit tests
pytest==9.1.1
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""Generate esphome/component_aliases.py from component ALIASES declarations.
Run without arguments to regenerate the registry; ``--check`` (run in CI)
verifies it is up to date.
"""
import argparse
from pathlib import Path
import sys
# The root directory of the repo
root = Path(__file__).parent.parent
# Make the repo's esphome package win over any installed copy
sys.path.insert(0, str(root))
from esphome.helpers import write_file_if_changed # noqa: E402
from esphome.loader import _build_alias_map # noqa: E402
parser = argparse.ArgumentParser()
parser.add_argument(
"--check",
help="Check if the alias registry is up to date.",
action="store_true",
)
args = parser.parse_args()
registry_file = root / "esphome" / "component_aliases.py"
HEADER = '''"""Component alias registry.
Generated by script/build_alias_registry.py - do not edit manually.
See the component-alias section of esphome/loader.py.
"""
# alias -> (canonical component, removal version or None)
COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
'''
# _build_alias_map scans the real component tree and already rejects
# duplicate and shadowing aliases with an EsphomeError.
_, alias_meta = _build_alias_map()
lines = [HEADER]
for alias, meta in sorted(alias_meta.items()):
removal = f'"{meta.removal_version}"' if meta.removal_version else "None"
lines.append(f' "{alias}": ("{meta.canonical}", {removal}),\n')
lines.append("}\n")
content = "".join(lines)
if args.check:
if registry_file.read_text(encoding="utf-8") != content:
print("Component alias registry is not up to date.")
print("Please run `script/build_alias_registry.py`")
sys.exit(1)
print("Component alias registry is up to date")
else:
write_file_if_changed(registry_file, content)
print(f"Wrote {registry_file}")
+1 -1
View File
@@ -557,7 +557,7 @@ def lint_constants_usage():
# Maximum allowed CONF_ constants in esphome/const.py.
# This file is frozen — new constants go in esphome/components/const/__init__.py.
# Decrease this number when constants are moved out of const.py.
CONST_PY_MAX_CONF = 1025
CONST_PY_MAX_CONF = 1017
@lint_content_check(include=["esphome/const.py"])
@@ -57,12 +57,7 @@ def test_bk72xx_defaults_are_valid() -> None:
def test_esp32_defaults_are_valid() -> None:
"""esp32 pins the ESP-IDF reference rate and exposes active (default on).
Without wifi loaded, the conditional window default falls back to the
historical 30 ms; the wifi-aware resolution is covered by the
esp32_ble_tracker component tests.
"""
"""esp32 pins the ESP-IDF reference rate and exposes active (default on)."""
config = ESP32_SCHEMA({})
assert to_ble_units(config["interval"]) == 512
assert to_ble_units(config["window"]) == 48
@@ -1,122 +0,0 @@
"""Tests for the esp32_ble_tracker conditional scan window default.
The scan window default depends on wifi coexistence and the IDF version:
IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the
configured window (espressif/esp-idf#18931), so on fixed versions the
historical 30 ms default would only listen 9.4 % of the time and miss most
advertisements. With the coexistence arbiter compiled in on a fixed IDF, the
window instead defaults to the interval, as Espressif recommends; without the
arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
"""
from __future__ import annotations
from collections.abc import Callable
import pytest
from esphome import config_validation as cv
from esphome.components.ble_device_base import to_ble_units
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_ble_tracker import (
CONF_SOFTWARE_COEXISTENCE,
CONFIG_SCHEMA,
)
from esphome.const import CONF_INTERVAL, PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
from ..types import SetCoreConfigCallable
@pytest.fixture
def stage_esp32(
set_core_config: SetCoreConfigCallable,
) -> Callable[..., None]:
"""Stage an esp32 build with a given IDF version and wifi presence."""
def stage(idf: str, *, wifi: bool) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
if wifi:
# Makes cv.OnlyWith default software_coexistence to True, exactly
# as a real config with wifi: does.
CORE.loaded_integrations.add("wifi")
return stage
def _scan_params(config: ConfigType) -> ConfigType:
return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS]
@pytest.mark.parametrize(
("idf", "config", "expected_units"),
[
("5.5.5", {}, 512), # first fixed version, default 320 ms interval
("6.0.1", {}, 512), # any newer version behaves the same
# Follows a user-set interval.
("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600),
],
)
def test_wifi_on_fixed_idf_defaults_window_to_interval(
stage_esp32: Callable[..., None],
idf: str,
config: ConfigType,
expected_units: int,
) -> None:
"""With wifi coexistence on a fixed IDF, the window defaults to the interval."""
stage_esp32(idf, wifi=True)
params = _scan_params(config)
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_WINDOW]) == expected_units
@pytest.mark.parametrize(
("idf", "wifi", "config"),
[
# Buggy IDF over-scans anyway; keep the 30 ms default.
("5.5.4", True, {}),
# No wifi (e.g. ethernet) means no radio contention.
("5.5.5", False, {}),
# Coexistence disabled: no arbiter, so a full-duty scan would starve
# wifi outright.
("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}),
],
)
def test_30ms_default_kept(
stage_esp32: Callable[..., None],
idf: str,
wifi: bool,
config: ConfigType,
) -> None:
stage_esp32(idf, wifi=wifi)
assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48
@pytest.mark.parametrize("window", ["60ms", "30ms"])
def test_explicit_window_is_never_touched(
stage_esp32: Callable[..., None], window: str
) -> None:
"""A user-set window wins over the conditional default.
The explicit 30 ms case matters: it is indistinguishable from the
defaulted value by inspection, so the defaulted flag must separate them.
"""
stage_esp32("5.5.5", wifi=True)
params = _scan_params({"scan_parameters": {"window": window}})
assert to_ble_units(params[CONF_WINDOW]) == to_ble_units(
cv.positive_time_period(window)
)
def test_short_interval_without_window_still_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""The provisional 30 ms default validates against the interval as before."""
stage_esp32("5.5.5", wifi=True)
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
_scan_params({"scan_parameters": {"interval": "20ms"}})
+14 -13
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
if TYPE_CHECKING:
from aioesphomeapi import DeviceInfo, EntityInfo
@@ -25,15 +25,16 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool:
return device_info.name.endswith(f"-{mac_suffix}")
def _get_name_for_object_id(
def _resolve_entity_name(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> str:
"""Get the name used for object_id computation.
"""Resolve the effective name for an entity.
This is the algorithm that aioesphomeapi will use to determine which
name to use for computing object_id client-side from API data.
name to use for computing object_id client-side from API data; the same
name is what the device hashes into the entity key.
Args:
entity: The entity to get name for
@@ -72,27 +73,27 @@ def compute_entity_object_id(
Returns:
The computed object_id string
"""
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
return compute_object_id(name_for_id)
name = _resolve_entity_name(entity, device_info, device_id_to_name)
return compute_object_id(name)
def compute_entity_hash(
def compute_entity_key(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> int:
"""Compute expected object_id hash for an entity.
"""Compute expected entity key for an entity.
Args:
entity: The entity to compute hash for
entity: The entity to compute the key for
device_info: Device info from the API
device_id_to_name: Mapping of device_id to device name for sub-devices
Returns:
The computed FNV-1 hash
The computed FNV-1 hash of the raw name
"""
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
return fnv1_hash_object_id(name_for_id)
name = _resolve_entity_name(entity, device_info, device_id_to_name)
return fnv1_hash_name(name)
def verify_entity_object_id(
@@ -118,7 +119,7 @@ def verify_entity_object_id(
f"expected '{expected_object_id}', got '{entity.object_id}'"
)
expected_hash = compute_entity_hash(entity, device_info, device_id_to_name)
expected_hash = compute_entity_key(entity, device_info, device_id_to_name)
assert entity.key == expected_hash, (
f"hash mismatch for entity '{entity.name}': "
f"expected {expected_hash:#x}, got {entity.key:#x}"
@@ -71,6 +71,38 @@ esphome:
ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty);
}
// Raw name hash: matches Python fnv1_hash_name("My Sensor Name")
uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14);
if (hash_raw == 0x8cec6fb0) {
ESP_LOGI("FNV1_OID", "raw PASSED");
} else {
ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw);
}
// Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température")
uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12);
if (hash_raw_utf8 == 0x531a74aa) {
ESP_LOGI("FNV1_OID", "raw_utf8 PASSED");
} else {
ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8);
}
// Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température")
uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true);
if (hash_old_utf8 == 0x965698f3) {
ESP_LOGI("FNV1_OID", "old_utf8 PASSED");
} else {
ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8);
}
// Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度")
uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true);
if (hash_old_cjk == 0x3276cb9f) {
ESP_LOGI("FNV1_OID", "old_cjk PASSED");
} else {
ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk);
}
host:
api:
logger:
@@ -156,10 +156,17 @@ button:
ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str());
ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str());
ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str());
// Log preference hashes for entities that actually store preferences
ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash());
ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash());
ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash());
ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash());
ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash());
ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash());
// Log preference key bases for entities that actually store preferences.
// This is the key base make_entity_preference() uses: entity key XOR device id.
ESP_LOGI("test", "Device A Switch Pref Hash: %u",
id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero());
ESP_LOGI("test", "Device B Switch Pref Hash: %u",
id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero());
ESP_LOGI("test", "Main Switch Pref Hash: %u",
id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero());
ESP_LOGI("test", "Device A Number Pref Hash: %u",
id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero());
ESP_LOGI("test", "Device B Number Pref Hash: %u",
id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero());
ESP_LOGI("test", "Main Number Pref Hash: %u",
id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero());
@@ -1,5 +1,5 @@
esphome:
name: host-pref-key-stability
name: host-pref-key-migration
host:
api:
@@ -37,6 +37,10 @@ async def test_fnv1_hash_object_id(
"special",
"complex",
"empty",
"raw",
"raw_utf8",
"old_utf8",
"old_cjk",
}
def on_log_line(line: str) -> None:
@@ -2,8 +2,8 @@
This test verifies a three-way match between:
1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char)
2. C++ hash generation (fnv1_hash_object_id in helpers.h)
3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id)
2. C++ entity key generation (fnv1_hash of the raw name in helpers.h)
3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py)
The API response contains C++ computed values, so verifying API == Python
implicitly verifies C++ == Python == API for both object_id and hash.
@@ -25,7 +25,7 @@ from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_object_id
from esphome.helpers import fnv1_hash_name
from .entity_utils import compute_object_id, verify_all_entities
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -123,7 +123,7 @@ async def test_object_id_api_verification(
)
# Verify hash can be computed from the name
hash_from_name = fnv1_hash_object_id(entity_name)
hash_from_name = fnv1_hash_name(entity_name)
assert hash_from_name == entity.key, (
f"Entity '{entity_name}': hash mismatch. "
f"Python hash {hash_from_name:#x}, API key {entity.key:#x}"
@@ -164,7 +164,7 @@ async def test_object_id_api_verification(
)
# Verify hash matches
expected_hash = fnv1_hash_object_id(expected_name)
expected_hash = fnv1_hash_name(expected_name)
assert entity.key == expected_hash, (
f"Empty-name entity (device_id={entity.device_id}): hash mismatch. "
f"API key: {entity.key:#x}, expected: {expected_hash:#x}"
@@ -11,7 +11,7 @@ from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_object_id
from esphome.helpers import fnv1_hash_name
from .entity_utils import (
compute_object_id,
@@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix(
)
# Hash should match friendly_name
expected_hash = fnv1_hash_object_id("My Friendly Device")
expected_hash = fnv1_hash_name("My Friendly Device")
assert entity.key == expected_hash, (
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
)
@@ -17,7 +17,7 @@ from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_object_id
from esphome.helpers import fnv1_hash_name
from .entity_utils import compute_object_id, verify_all_entities
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix(
OLD behavior:
- is_object_id_dynamic_() returned false (mac suffix not enabled)
- Used object_id_c_str_ which was pre-computed in Python
- Python used get_base_entity_object_id() with fallback to CORE.name
- Python used get_base_entity_name() with fallback to CORE.name
Result: object_id = sanitize(snake_case(device_name))
"""
@@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix(
)
# Hash should match device name
expected_hash = fnv1_hash_object_id("test-device")
expected_hash = fnv1_hash_name("test-device")
assert entity.key == expected_hash, (
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
)
@@ -1,14 +1,14 @@
"""Integration test for entity preference key stability.
"""Integration test for entity preference key migration.
Entity preferences are stored under keys derived from the sanitized object_id
hash. This test seeds the host preferences file the way existing firmware
wrote it and verifies the state is restored, proving the key scheme has not
drifted; a save and reload round trip cannot catch drift because it writes
and reads with the same code.
Entity keys are now the FNV-1 hash of the raw name instead of the sanitized
object_id (https://github.com/esphome/backlog/issues/85). On key-lookup
preference backends, make_entity_preference() must move data stored under the
old key to the new key, so devices keep their restored state after upgrading.
The second run also seeds the raw-name-hash entries a 2026.8 beta device left
behind (see https://github.com/esphome/esphome/pull/18361) and proves they are
ignored: the object_id entries win and the beta leftovers are inert.
This test seeds the host preferences file the way a pre-migration firmware
would have written it and verifies:
1. Data stored under the OLD key is restored (migration happened, no data loss)
2. Data already stored under the NEW key is never overwritten by old data
"""
from __future__ import annotations
@@ -33,23 +33,22 @@ from .host_prefs import clear_host_prefs, write_host_prefs
from .state_utils import InitialStateHelper, require_entity
from .types import CompileFunction, ConfigWriter
DEVICE_NAME = "host-pref-key-stability"
DEVICE_NAME = "host-pref-key-migration"
# All entities are on the main device (device_id 0) and their preferences use
# no version salt, so the key is just the object_id hash.
SWITCH_KEY = fnv1_hash_object_id("Test Switch")
NUMBER_KEY = fnv1_hash_object_id("Test Number")
# Raw-name-hash keys as written by 2026.8 beta firmware; never read by this build
SWITCH_BETA_KEY = fnv1_hash_name("Test Switch")
NUMBER_BETA_KEY = fnv1_hash_name("Test Number")
# The pre-migration preference key was the sanitized object_id hash; the new
# key is the raw-name hash. All entities are on the main device (device_id 0)
# and their preferences use no version salt, so the key is just the hash.
SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch")
SWITCH_NEW_KEY = fnv1_hash_name("Test Switch")
NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number")
NUMBER_NEW_KEY = fnv1_hash_name("Test Number")
# template_text salts its key with the length limits and pattern hash; this must
# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20,
# no pattern configured)
TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6)
TEXT_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
TEXT_BETA_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes
TEXT_MAX_LENGTH = 20
@@ -63,18 +62,18 @@ def text_pref_payload(value: str) -> bytes:
@pytest.mark.asyncio
async def test_preference_key_stability(
async def test_preference_key_migration(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
reserved_tcp_port: tuple[int, socket.socket],
) -> None:
"""Test that preferences stored by earlier firmware are restored."""
"""Test that preferences stored under the old key survive the upgrade."""
port, port_socket = reserved_tcp_port
assert SWITCH_KEY != SWITCH_BETA_KEY
assert NUMBER_KEY != NUMBER_BETA_KEY
assert TEXT_KEY != TEXT_BETA_KEY
assert SWITCH_OLD_KEY != SWITCH_NEW_KEY
assert NUMBER_OLD_KEY != NUMBER_NEW_KEY
assert TEXT_OLD_KEY != TEXT_NEW_KEY
# Write and compile once
config_path = await write_yaml_config(yaml_config)
@@ -118,51 +117,49 @@ async def test_preference_key_stability(
return switch_state, number_state, text_state
try:
# --- Run 1: entries under the object_id-hash keys, exactly as any
# earlier firmware wrote them. The restored states prove the key
# scheme has not drifted.
# --- Run 1: only OLD keys present, as written by pre-migration firmware.
# The restored states prove the data was migrated to the new keys.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_KEY: b"\x01", # bool: switch was ON
NUMBER_KEY: struct.pack("<f", 42.5),
TEXT_KEY: text_pref_payload("hello"),
SWITCH_OLD_KEY: b"\x01", # bool: switch was ON
NUMBER_OLD_KEY: struct.pack("<f", 42.5),
TEXT_OLD_KEY: text_pref_payload("hello"),
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Switch state stored under the object_id preference key was lost"
"Switch state stored under the old preference key was lost"
)
assert number_state.state == 42.5, (
"Number value stored under the object_id preference key was lost"
"Number value stored under the old preference key was lost"
)
assert text_state.state == "hello", (
"Text value stored under the object_id preference key was lost"
"Text value stored under the old preference key was lost"
)
# --- Run 2: raw-name-hash entries from a 2026.8 beta device present
# alongside the object_id entries. The object_id data must win; the
# beta entries are never read.
# --- Run 2: both keys present with different values. The NEW key holds
# the current data and must win; stale old-key data must never clobber it.
write_host_prefs(
DEVICE_NAME,
{
SWITCH_KEY: b"\x01", # current: ON
SWITCH_BETA_KEY: b"\x00", # beta leftover: OFF
NUMBER_KEY: struct.pack("<f", 13.5), # current
NUMBER_BETA_KEY: struct.pack("<f", 99.5), # beta leftover
TEXT_KEY: text_pref_payload("world"), # current
TEXT_BETA_KEY: text_pref_payload("ignored"), # beta leftover
SWITCH_OLD_KEY: b"\x00", # stale: OFF
SWITCH_NEW_KEY: b"\x01", # current: ON
NUMBER_OLD_KEY: struct.pack("<f", 42.5), # stale
NUMBER_NEW_KEY: struct.pack("<f", 13.5), # current
TEXT_OLD_KEY: text_pref_payload("hello"), # stale
TEXT_NEW_KEY: text_pref_payload("world"), # current
},
)
switch_state, number_state, text_state = await boot_and_get_initial_states()
assert switch_state.state is True, (
"Beta raw-name-key data overrode the object_id switch state"
"Stale old-key data overwrote the current new-key switch state"
)
assert number_state.state == 13.5, (
"Beta raw-name-key data overrode the object_id number value"
"Stale old-key data overwrote the current new-key number value"
)
assert text_state.state == "world", (
"Beta raw-name-key data overrode the object_id text value"
"Stale old-key data overwrote the current new-key text value"
)
finally:
clear_host_prefs(DEVICE_NAME)
@@ -0,0 +1,239 @@
"""Tests for the MQTT object_id conflict filter.
MQTT still builds default topics and discovery topics from the sanitized
object_id, so entity names that only differ in characters lost during
sanitizing conflict there; _topics_conflict() exempts entities that never
use an object_id-derived topic. See https://github.com/esphome/backlog/issues/85
"""
from pathlib import Path
import pytest
from esphome.components.mqtt import (
_COMMAND_TOPIC_PLATFORMS,
_SUB_TOPIC_PLATFORMS,
_topics_conflict,
)
from esphome.config_validation import Invalid
from esphome.const import (
CONF_COMMAND_TOPIC,
CONF_DISCOVERY,
CONF_NAME,
CONF_STATE_TOPIC,
CONF_TOPIC_PREFIX,
)
from esphome.core import CORE
from esphome.core.entity_helpers import (
entity_duplicate_validator,
validate_no_object_id_conflicts,
)
COMPONENTS_DIR = Path(__file__).parents[4] / "esphome" / "components"
REASON = "mqtt builds default topics from the entity object_id"
# MQTT infrastructure sources, not entity components
_NON_ENTITY_MQTT_SOURCES = {"mqtt_client", "mqtt_component"}
# The date, time and datetime MQTT components all belong to the datetime platform
_DATETIME_STEMS = {"date", "time", "datetime"}
def test_command_topic_platforms_in_sync() -> None:
"""Verify _COMMAND_TOPIC_PLATFORMS matches the MQTT components that subscribe.
Drift silently reintroduces shared subscribe topics, so this derives the set
from the C++ components that actually call subscribe(); that also catches
platforms like text that subscribe a command topic without exposing a
command_topic key in their schema.
"""
expected: set[str] = set()
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.cpp"):
if path.stem in _NON_ENTITY_MQTT_SOURCES:
continue
if "this->subscribe" not in path.read_text(encoding="utf-8"):
continue
stem = path.stem.removeprefix("mqtt_")
expected.add("datetime" if stem in _DATETIME_STEMS else stem)
assert expected == _COMMAND_TOPIC_PLATFORMS
def test_sub_topic_platforms_in_sync() -> None:
"""Verify _SUB_TOPIC_PLATFORMS matches the MQTT components with sub-topics.
Platforms whose MQTT headers use MQTT_COMPONENT_CUSTOM_TOPIC derive extra
topics such as position/command from the object_id.
"""
expected = {
path.stem.removeprefix("mqtt_")
for path in (COMPONENTS_DIR / "mqtt").glob("mqtt_*.h")
if path.stem != "mqtt_component"
and "MQTT_COMPONENT_CUSTOM_TOPIC" in path.read_text(encoding="utf-8")
}
assert expected == _SUB_TOPIC_PLATFORMS
def test_conflict_filter_exempts_custom_topics() -> None:
"""Test that custom state topics with discovery off avoid the conflict."""
validator = entity_duplicate_validator("sensor")
# Both entities have custom state topics and discovery disabled per entity,
# so no object_id-derived MQTT topic is used
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
assert component_validator(config) is config
# Without the filter the same conflicts are fatal
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
validate_no_object_id_conflicts(REASON)({})
def test_conflict_on_default_command_topic() -> None:
"""Test that commandable platforms conflict through their default command topic.
Custom state topics with discovery off are not enough for platforms that also
subscribe to an object_id-derived command topic.
"""
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
mqtt_config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
# Both switches share the default command topic: rejected
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator(mqtt_config)
# With custom command topics as well, nothing derives from the object_id
CORE.reset()
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_COMMAND_TOPIC: "custom/cmd/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
assert component_validator(mqtt_config) is mqtt_config
def test_conflict_on_sub_topic_platforms() -> None:
"""Test that platforms with extra object_id sub-topics always conflict.
Covers derive topics like position/command from the object_id through their
own config keys, so custom state and command topics cannot exempt them.
"""
validator = entity_duplicate_validator("cover")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_COMMAND_TOPIC: "custom/cmd/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_STATE_TOPIC: "custom/topic/b",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"})
def test_no_conflict_on_disjoint_default_topics() -> None:
"""Test that entities whose default topics are disjoint do not conflict.
One entity uses only the default command topic and the other only the default
state topic, so they never share a topic.
"""
validator = entity_duplicate_validator("switch")
validator(
{
CONF_NAME: "Датчик открытия",
CONF_STATE_TOPIC: "custom/topic/a",
CONF_DISCOVERY: False,
}
)
validator(
{
CONF_NAME: "Датчик закрытия",
CONF_COMMAND_TOPIC: "custom/cmd/b",
CONF_DISCOVERY: False,
}
)
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
config: dict = {CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: "test-device"}
assert component_validator(config) is config
def test_no_conflict_on_empty_topic_prefix() -> None:
"""Test that an empty topic_prefix disables the default topic conflict.
With topic_prefix set to null no default topics exist at runtime, so entities
without custom state topics cannot conflict; only discovery still matters.
"""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
component_validator = validate_no_object_id_conflicts(
REASON, conflict_filter=_topics_conflict
)
# No default topics and no discovery: valid
config: dict = {CONF_DISCOVERY: False, CONF_TOPIC_PREFIX: ""}
assert component_validator(config) is config
# Discovery still uses object_id-derived config topics: rejected
with pytest.raises(Invalid, match=r"mqtt builds default topics"):
component_validator({CONF_DISCOVERY: True, CONF_TOPIC_PREFIX: ""})
+185 -331
View File
@@ -1,11 +1,9 @@
"""Test get_base_entity_object_id function matches C++ behavior."""
"""Tests for entity helpers: name selection, entity key hashing, duplicate checks."""
from collections.abc import Callable, Generator
import logging
from pathlib import Path
import re
from typing import Any
from unittest.mock import patch
import pytest
@@ -23,23 +21,21 @@ from esphome.const import (
)
from esphome.core import CORE, ID, entity_helpers
from esphome.core.entity_helpers import (
_check_report_deprecation,
_register_string,
_setup_entity_impl,
entity_duplicate_validator,
finalize_entity_strings,
get_base_entity_object_id,
lazy_load_validator,
mqtt_component_class,
get_base_entity_name,
register_device_class,
register_icon,
register_unit_of_measurement,
setup_device_class,
setup_entity,
setup_unit_of_measurement,
validate_no_object_id_conflicts,
)
from esphome.cpp_generator import MockObj
from esphome.helpers import fnv1_hash, sanitize, snake_case
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
from .common import load_config_from_fixture
@@ -62,206 +58,26 @@ def restore_core_state() -> Generator[None, None, None]:
CORE.friendly_name = original_friendly_name
def test_with_entity_name() -> None:
"""Test when entity has its own name - should use entity name."""
# Simple name
assert get_base_entity_object_id("Temperature Sensor", None) == "temperature_sensor"
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name")
== "temperature_sensor"
)
# Even with device name, entity name takes precedence
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name", "Sub Device")
== "temperature_sensor"
)
# Name with special characters
assert (
get_base_entity_object_id("Temp!@#$%^&*()Sensor", None)
== "temp__________sensor"
)
assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123"
# Already snake_case
assert get_base_entity_object_id("temperature_sensor", None) == "temperature_sensor"
# Mixed case
assert get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor"
assert get_base_entity_object_id("TEMPERATURE SENSOR", None) == "temperature_sensor"
def test_empty_name_with_device_name() -> None:
"""Test when entity has empty name and is on a sub-device - should use device name."""
# C++ behavior: when has_own_name is false and device is set, uses device->get_name()
assert (
get_base_entity_object_id("", "Friendly Device", "Sub Device 1")
== "sub_device_1"
)
assert (
get_base_entity_object_id("", "Kitchen Controller", "controller_1")
== "controller_1"
)
assert get_base_entity_object_id("", None, "Test-Device_123") == "test-device_123"
def test_empty_name_with_friendly_name() -> None:
"""Test when entity has empty name and no device - should use friendly name."""
# C++ behavior: when has_own_name is false, uses App.get_friendly_name()
assert get_base_entity_object_id("", "Friendly Device") == "friendly_device"
assert get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller"
assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123"
# Special characters in friendly name
assert get_base_entity_object_id("", "Device!@#$%") == "device_____"
def test_empty_name_no_friendly_name() -> None:
"""Test when entity has empty name and no friendly name - should use device name."""
# Test with CORE.name set
CORE.name = "device-name"
assert get_base_entity_object_id("", None) == "device-name"
CORE.name = "Test Device"
assert get_base_entity_object_id("", None) == "test_device"
def test_edge_cases() -> None:
"""Test edge cases."""
# Only spaces
assert get_base_entity_object_id(" ", None) == "___"
# Unicode characters (should be replaced)
assert get_base_entity_object_id("Température", None) == "temp_rature"
assert get_base_entity_object_id("测试", None) == "__"
# Empty string with empty friendly name (empty friendly name is treated as None)
# Falls back to CORE.name
CORE.name = "device"
assert get_base_entity_object_id("", "") == "device"
# Very long name (should work fine)
long_name = "a" * 100 + " " + "b" * 100
expected = "a" * 100 + "_" + "b" * 100
assert get_base_entity_object_id(long_name, None) == expected
@pytest.mark.parametrize(
("name", "expected"),
[
("Temperature Sensor", "temperature_sensor"),
("Living Room Light", "living_room_light"),
("Test-Device_123", "test-device_123"),
("Special!@#Chars", "special___chars"),
("UPPERCASE NAME", "uppercase_name"),
("lowercase name", "lowercase_name"),
("Mixed Case Name", "mixed_case_name"),
(" Spaces ", "___spaces___"),
],
)
def test_matches_cpp_helpers(name: str, expected: str) -> None:
"""Test that the logic matches using snake_case and sanitize directly."""
# For non-empty names, verify our function produces same result as direct snake_case + sanitize
assert get_base_entity_object_id(name, None) == sanitize(snake_case(name))
assert get_base_entity_object_id(name, None) == expected
def test_empty_name_fallback() -> None:
"""Test empty name handling which falls back to friendly_name or CORE.name."""
# Empty name is handled specially - it doesn't just use sanitize(snake_case(""))
# Instead it falls back to friendly_name or CORE.name
assert sanitize(snake_case("")) == "" # Direct conversion gives empty string
# But our function returns a fallback
CORE.name = "device"
assert get_base_entity_object_id("", None) == "device" # Uses device name
def test_name_add_mac_suffix_behavior() -> None:
"""Test behavior related to name_add_mac_suffix.
In C++, an entity's object_id is computed from its name_ via
write_object_id_to() (sanitized snake_case). When an entity has no name,
configure_entity_() sets name_ from the friendly name, with the MAC suffix
appended when name_add_mac_suffix is enabled. Our function always returns
the same result since we're calculating the base for duplicate tracking.
"""
# The function should always return the same result regardless of
# name_add_mac_suffix setting, as we're calculating the base object_id
assert get_base_entity_object_id("", "Test Device") == "test_device"
assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"
def test_priority_order() -> None:
def test_get_base_entity_name_priority_order() -> None:
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
CORE.name = "core-device"
# 1. Entity name has highest priority
# 1. Entity name has highest priority and is used as-is, no transformations
assert (
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
== "entity_name"
get_base_entity_name("Entity Name", "Friendly Name", "Device Name")
== "Entity Name"
)
assert get_base_entity_name("Température", None) == "Température"
# 2. Device name is next priority (when entity name is empty)
assert (
get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name"
)
assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name"
# 3. Friendly name is next (when entity and device names are empty)
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name"
# 4. CORE.name is last resort
assert get_base_entity_object_id("", None, None) == "core-device"
@pytest.mark.parametrize(
("name", "friendly_name", "device_name", "expected"),
[
# name, friendly_name, device_name, expected
("Living Room Light", None, None, "living_room_light"),
("", "Kitchen Controller", None, "kitchen_controller"),
(
"",
"ESP32 Device",
"controller_1",
"controller_1",
), # Device name takes precedence
("GPIO2 Button", None, None, "gpio2_button"),
("WiFi Signal", "My Device", None, "wifi_signal"),
("", None, "esp32_node", "esp32_node"),
("Front Door Sensor", "Home Assistant", "door_controller", "front_door_sensor"),
],
)
def test_real_world_examples(
name: str, friendly_name: str | None, device_name: str | None, expected: str
) -> None:
"""Test real-world entity naming scenarios."""
result = get_base_entity_object_id(name, friendly_name, device_name)
assert result == expected
def test_issue_6953_scenarios() -> None:
"""Test specific scenarios from issue #6953."""
# Scenario 1: Multiple empty names on main device with name_add_mac_suffix
# The Python code calculates the base, C++ might append MAC suffix dynamically
CORE.name = "device-name"
CORE.friendly_name = "Friendly Device"
# All empty names should resolve to same base
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
assert get_base_entity_object_id("", CORE.friendly_name) == "friendly_device"
# Scenario 2: Empty names on sub-devices
assert (
get_base_entity_object_id("", "Main Device", "controller_1") == "controller_1"
)
assert (
get_base_entity_object_id("", "Main Device", "controller_2") == "controller_2"
)
# Scenario 3: xyz duplicates
assert get_base_entity_object_id("xyz", None) == "xyz"
assert get_base_entity_object_id("xyz", "Device") == "xyz"
# 4. CORE.name is last resort; an empty friendly name falls through to it
assert get_base_entity_name("", None, None) == "core-device"
assert get_base_entity_name("", "") == "core-device"
# Tests for setup_entity function
@@ -520,9 +336,10 @@ def test_entity_duplicate_validator() -> None:
config1 = {CONF_NAME: "Temperature"}
validated1 = validator(config1)
assert validated1 == config1
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
assert temperature_key in CORE.unique_ids
# Check metadata was stored
metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))]
metadata = CORE.unique_ids[temperature_key]
assert metadata["name"] == "Temperature"
assert metadata["platform"] == "sensor"
@@ -530,8 +347,9 @@ def test_entity_duplicate_validator() -> None:
config2 = {CONF_NAME: "Humidity"}
validated2 = validator(config2)
assert validated2 == config2
assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids
metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))]
humidity_key = ("", "sensor", fnv1_hash_name("Humidity"))
assert humidity_key in CORE.unique_ids
metadata2 = CORE.unique_ids[humidity_key]
assert metadata2["name"] == "Humidity"
# Duplicate entity should fail
@@ -542,34 +360,6 @@ def test_entity_duplicate_validator() -> None:
validator(config3)
def test_entity_duplicate_validator_hash_collision() -> None:
"""Test that two different object_ids with the same FNV-1 hash are rejected."""
# Brute-forced FNV-1 32-bit collision pair; both object_ids hash to 0xe95747e4
name_a = "Sensor aooxzi"
name_b = "Sensor baraia"
object_id_a = sanitize(snake_case(name_a))
object_id_b = sanitize(snake_case(name_b))
assert object_id_a != object_id_b
assert fnv1_hash(object_id_a) == fnv1_hash(object_id_b)
validator = entity_duplicate_validator("sensor")
config1 = {CONF_NAME: name_a}
validated1 = validator(config1)
assert validated1 == config1
config2 = {CONF_NAME: name_b}
with pytest.raises(
Invalid,
match=re.compile(
r"Duplicate sensor entity with name 'Sensor baraia' found.*"
r"produce the same entity key hash \(0xe95747e4\)",
re.DOTALL,
),
):
validator(config2)
def test_entity_duplicate_validator_with_devices() -> None:
"""Test entity_duplicate_validator with devices."""
# Create validator for sensor platform
@@ -580,18 +370,19 @@ def test_entity_duplicate_validator_with_devices() -> None:
device2 = ID("device2", type="Device")
# Same name on different devices should pass
name_hash = fnv1_hash_name("Temperature")
config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
validated1 = validator(config1)
assert validated1 == config1
assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))]
assert ("device1", "sensor", name_hash) in CORE.unique_ids
metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)]
assert metadata1["device_id"] == "device1"
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
validated2 = validator(config2)
assert validated2 == config2
assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))]
assert ("device2", "sensor", name_hash) in CORE.unique_ids
metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)]
assert metadata2["device_id"] == "device2"
# Duplicate on same device should fail
@@ -643,6 +434,33 @@ def test_entity_different_platforms_yaml_validation(
assert result is not None
def test_object_id_conflict_mqtt_yaml_validation(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that names sanitizing to the same object_id fail when mqtt is configured."""
result = load_config_from_fixture(
yaml_file, "object_id_conflict_mqtt.yaml", FIXTURES_DIR
)
assert result is None
captured = capsys.readouterr()
assert (
"mqtt builds default topics and discovery topics from the entity object_id"
in captured.out
)
def test_object_id_conflict_without_mqtt_yaml_validation(
yaml_file: Callable[[str], str],
) -> None:
"""Test that names sanitizing to the same object_id pass without mqtt/prometheus."""
result = load_config_from_fixture(
yaml_file, "object_id_conflict_no_mqtt.yaml", FIXTURES_DIR
)
# This should succeed
assert result is not None
def test_entity_duplicate_validator_error_message() -> None:
"""Test that duplicate entity error messages include helpful metadata."""
# Create validator for sensor platform
@@ -701,7 +519,8 @@ def test_entity_duplicate_validator_internal_entities() -> None:
validated1 = validator(config1)
assert validated1 == config1
# New format includes device_id (empty string for main device)
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
assert temperature_key in CORE.unique_ids
# Internal entity with same name should pass (not added to unique_ids)
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
@@ -709,9 +528,7 @@ def test_entity_duplicate_validator_internal_entities() -> None:
assert validated2 == config2
# Internal entity should not be added to unique_ids
# Count how many times the key appears (should still be 1)
count = sum(
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
)
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
assert count == 1
# Another internal entity with same name should also pass
@@ -719,9 +536,7 @@ def test_entity_duplicate_validator_internal_entities() -> None:
validated3 = validator(config3)
assert validated3 == config3
# Still only one entry in unique_ids (from the non-internal entity)
count = sum(
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
)
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
assert count == 1
# Non-internal entity with same name should fail
@@ -749,30 +564,148 @@ def test_empty_or_null_device_id_on_entity() -> None:
def test_entity_duplicate_validator_non_ascii_names() -> None:
"""Test that non-ASCII names show helpful error messages."""
"""Test that distinct non-ASCII names no longer collide.
These names used to be rejected because both sanitize to only underscores;
the entity key now hashes the raw name so they stay distinct.
"""
# Create validator for binary_sensor platform
validator = entity_duplicate_validator("binary_sensor")
# First Russian sensor should pass
# Both Russian sensors should pass even though they sanitize identically
config1 = {CONF_NAME: "Датчик открытия основного крана"}
validated1 = validator(config1)
assert validated1 == config1
# Second Russian sensor with different text but same ASCII conversion should fail
config2 = {CONF_NAME: "Датчик закрытия основного крана"}
validated2 = validator(config2)
assert validated2 == config2
# An exact duplicate still fails
config3 = {CONF_NAME: "Датчик открытия основного крана"}
with pytest.raises(
Invalid,
match=r"Duplicate binary_sensor entity with name 'Датчик открытия основного крана' found",
):
validator(config3)
def test_entity_duplicate_validator_hash_collision() -> None:
"""Test that two different names with the same FNV-1 hash are rejected."""
# Brute-forced FNV-1 32-bit collision pair; both hash to 0x0ee5ff7b
name_a = "Sensor m2CZ"
name_b = "Sensor qCaa"
assert name_a != name_b
assert fnv1_hash_name(name_a) == fnv1_hash_name(name_b)
validator = entity_duplicate_validator("sensor")
config1 = {CONF_NAME: name_a}
validated1 = validator(config1)
assert validated1 == config1
config2 = {CONF_NAME: name_b}
with pytest.raises(
Invalid,
match=re.compile(
r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*"
r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*"
r"Both convert to ASCII ID: '_______________________________'.*"
r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)",
rf"Duplicate sensor entity with name '{name_b}' found.*"
rf"The names '{name_b}' and '{name_a}' produce the.*"
r"same entity key hash \(0x0ee5ff7b\).*"
r"To fix: Rename one of the entities",
re.DOTALL,
),
):
validator(config2)
def test_object_id_conflicts_rejected_by_component_validator() -> None:
"""Test that object_id conflicts pass entity validation but fail for mqtt/prometheus."""
validator = entity_duplicate_validator("sensor")
# Both names validate fine in general (distinct raw names, distinct keys)
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
# A component that addresses entities by object_id must reject the config
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
with pytest.raises(
Invalid,
match=re.compile(
r"mqtt builds default topics from the entity object_id.*"
r"sensor entities 'Датчик открытия', 'Датчик закрытия' "
r"share the object_id '_______________'.*"
r"To fix: Add unique ASCII characters",
re.DOTALL,
),
):
component_validator({})
def test_object_id_conflicts_skipped_in_testing_mode() -> None:
"""Test that testing_mode skips the conflict check, as used for grouped testing."""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Датчик открытия"})
validator({CONF_NAME: "Датчик закрытия"})
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
CORE.testing_mode = True
try:
config: dict = {}
assert component_validator(config) is config
finally:
CORE.testing_mode = False
def test_object_id_conflicts_none_recorded() -> None:
"""Test that distinct object_ids produce no conflicts."""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Temperature"})
validator({CONF_NAME: "Humidity"})
component_validator = validate_no_object_id_conflicts(
"mqtt builds default topics from the entity object_id"
)
config: dict = {}
assert component_validator(config) is config
def test_object_id_conflicts_device_scoped() -> None:
"""Test that the object_id conflict check is scoped per device.
Same-named entities on different sub-devices were accepted before entity keys
moved to raw names, so the check keeps that scope; conflicts within one device
are still reported with the device named in the message.
"""
validator = entity_duplicate_validator("sensor")
validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device1", type="Device")})
validator({CONF_NAME: "Temperature", CONF_DEVICE_ID: ID("device2", type="Device")})
component_validator = validate_no_object_id_conflicts(
"prometheus builds metric labels from the entity object_id"
)
config: dict = {}
assert component_validator(config) is config
# Two names sanitizing identically on the same sub-device still conflict
validator(
{CONF_NAME: "Датчик открытия", CONF_DEVICE_ID: ID("device1", type="Device")}
)
validator(
{CONF_NAME: "Датчик закрытия", CONF_DEVICE_ID: ID("device1", type="Device")}
)
with pytest.raises(
Invalid,
match=re.compile(
r"prometheus builds metric labels.*on device 'device1'", re.DOTALL
),
):
component_validator({})
def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None:
"""Test that identical names don't show the enhanced message."""
# Create validator for sensor platform
@@ -830,7 +763,7 @@ async def test_setup_entity_empty_name_with_device(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_object_id_hash") == 0
assert config.get("_entity_key") == 0
@pytest.mark.asyncio
@@ -859,7 +792,7 @@ async def test_setup_entity_empty_name_with_mac_suffix(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_object_id_hash") == 0
assert config.get("_entity_key") == 0
@pytest.mark.asyncio
@@ -889,7 +822,7 @@ async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_object_id_hash") == 0
assert config.get("_entity_key") == 0
@pytest.mark.asyncio
@@ -920,7 +853,7 @@ async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name(
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
assert config.get("_entity_name") == ""
assert config.get("_entity_object_id_hash") == 0
assert config.get("_entity_key") == 0
def test_register_string_overflow() -> None:
@@ -1241,82 +1174,3 @@ async def test_finalize_comment_sanitization(
# Newline must be replaced to prevent breaking out of comment
assert "\n" not in comment_line
assert "INJECTED_CODE" in comment_line # still visible but safe in comment
@pytest.mark.parametrize(
("value", "warns"),
[
("coordinator", True),
("enable", True),
("force", False),
("default", False),
],
)
def test_check_report_deprecation(
value: str, warns: bool, caplog: pytest.LogCaptureFixture
) -> None:
"""Deprecated zigbee report options warn; the value always passes through."""
with caplog.at_level(logging.WARNING):
assert _check_report_deprecation(value) == value
assert ("deprecated" in caplog.text) is warns
def test_lazy_load_validator_defers_import() -> None:
"""The validator no-ops without importing unless the component is loaded."""
validator = lazy_load_validator("zigbee", "validate_binary_sensor")
config = {CONF_NAME: "test"}
with (
patch.object(CORE, "loaded_integrations", set()),
patch("esphome.core.entity_helpers.import_module") as import_mock,
):
assert validator(config) is config
import_mock.assert_not_called()
CORE.loaded_integrations.add("zigbee")
delegate = import_mock.return_value.validate_binary_sensor
delegate.return_value = {CONF_NAME: "validated"}
assert validator(config) == {CONF_NAME: "validated"}
import_mock.assert_called_once_with("esphome.components.zigbee")
delegate.assert_called_once_with(config)
def test_lazy_load_validator_rejects_unknown_component() -> None:
"""A typo in the component name fails at schema construction."""
with pytest.raises(ValueError, match="no_such_component"):
lazy_load_validator("no_such_component", "validate_binary_sensor")
def test_lazy_load_validator_names_missing_hook() -> None:
"""A missing hook raises a clear error naming the component and hook."""
validator = lazy_load_validator("zigbee", "no_such_hook")
with (
patch.object(CORE, "loaded_integrations", {"zigbee"}),
patch("esphome.core.entity_helpers.import_module") as import_mock,
pytest.raises(ValueError, match="no_such_hook"),
):
del import_mock.return_value.no_such_hook
validator({})
def test_integration_class_handles_match_owning_definitions() -> None:
"""The cheap class handles must stay string-equal to the integrations'
own declarations, or use_id/declare_id resolution silently drifts."""
from esphome.components import mqtt, web_server
from esphome.components.zigbee import zigbee_zephyr
from esphome.components.zigbee.const import ZigbeeComponent
mqtt_handle = mqtt_component_class("MQTTBinarySensorComponent")
assert str(mqtt_handle) == str(mqtt.MQTTBinarySensorComponent)
assert mqtt_handle.inherits_from(mqtt.MQTTComponent)
assert mqtt.MQTTBinarySensorComponent.inherits_from(entity_helpers._MQTTComponent)
assert str(entity_helpers._WebServer) == str(web_server.WebServer)
assert entity_helpers._WebServer.inherits_from(web_server.WebServer)
assert web_server.WebServer.inherits_from(entity_helpers._WebServer)
assert str(entity_helpers._ZigbeeComponent) == str(ZigbeeComponent)
for _conf_key, class_name in entity_helpers._ZIGBEE_ENTITY_CLASSES.values():
owning = getattr(zigbee_zephyr, class_name)
assert str(owning) == f"zigbee::{class_name}"
@@ -0,0 +1,22 @@
esphome:
name: test-object-id-conflict
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
mqtt:
broker: test.mosquitto.org
sensor:
# Distinct raw names are fine in general, but both sanitize to the same
# object_id, which MQTT still uses to build default topics - should fail
- platform: template
name: "Датчик открытия"
lambda: return 21.0;
- platform: template
name: "Датчик закрытия"
lambda: return 22.0;
@@ -0,0 +1,15 @@
esphome:
name: test-object-id-ok
esp32:
board: esp32dev
sensor:
# Distinct raw names that sanitize to the same object_id are allowed when no
# component addresses entities by object_id (no mqtt or prometheus configured)
- platform: template
name: "Датчик открытия"
lambda: return 21.0;
- platform: template
name: "Датчик закрытия"
lambda: return 22.0;
-37
View File
@@ -19,8 +19,6 @@ from pathlib import Path
import subprocess
import sys
import pytest
# Modules that must only load for the commands that actually use them
# (compile/config validation, shell completion), never from a bare
# ``import esphome.__main__``.
@@ -192,41 +190,6 @@ def test_api_client_does_not_import_heavy_modules() -> None:
)
@pytest.mark.parametrize("component", ["binary_sensor", "sensor"])
def test_entity_component_does_not_import_integrations(component: str) -> None:
"""An entity component must not drag in its optional integrations.
The mqtt/web_server/zigbee schema fragments live in
``esphome.core.entity_helpers``; the integration packages (and the
esp32/logger chains mqtt and zigbee pull in) must only load when the
user's config actually uses them.
"""
allowed = (
"esphome.components",
f"esphome.components.{component}",
"esphome.components.const",
)
check = (
f"import sys; import esphome.components.{component}; "
f"leaked = [m for m in sys.modules "
f"if m.startswith('esphome.components') and m not in {allowed!r}]; "
"print(','.join(leaked))"
)
result = subprocess.run(
[sys.executable, "-c", check],
capture_output=True,
text=True,
check=True,
)
leaked = result.stdout.strip()
assert not leaked, (
f"esphome.components.{component} imports integration packages at "
f"top level: {leaked}. Keep the shared schema fragments in "
"esphome.core.entity_helpers and import the integration inside "
"to_code instead."
)
def test_stacktrace_does_not_import_heavy_modules() -> None:
"""``esphome.stacktrace`` guards its own docstring's contract.
-28
View File
@@ -8,7 +8,6 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.component_aliases import COMPONENT_ALIASES
from esphome.loader import (
AliasMeta,
ComponentManifest,
@@ -482,33 +481,6 @@ def test_real_alias_map_includes_rp2040() -> None:
assert meta["rp2040"].removal_version == "2027.7.0"
def test_alias_registry_matches_component_tree() -> None:
"""The checked-in registry must match a live scan of the component tree."""
_, meta_map = _build_alias_map()
expected = {
alias: (meta.canonical, meta.removal_version)
for alias, meta in meta_map.items()
}
assert expected == COMPONENT_ALIASES, (
"esphome/component_aliases.py is out of date; "
"run script/build_alias_registry.py"
)
def test_alias_map_built_from_registry() -> None:
"""The runtime alias map comes from the generated registry, not a scan."""
with (
patch(
"esphome.component_aliases.COMPONENT_ALIASES",
{"legacy": ("modern", "2099.1.0")},
),
patch("esphome.loader._ALIAS_META_CACHE", None),
):
assert get_alias_metadata() == {
"legacy": AliasMeta(canonical="modern", removal_version="2099.1.0")
}
def test_get_component_resolves_alias() -> None:
"""``get_component('rp2040')`` should return the rp2 manifest — every
caller of the loader (dep checker, schema validator, codegen) hits
@@ -5,11 +5,11 @@ users to lose stored preferences (calibration values, restore states, etc.) on
firmware upgrades, or break entity state routing to API clients.
Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85):
1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1).
The entity key sent to API clients and the base of every stored preference key.
2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta
firmware stored preferences under keys derived from it; a future key migration
must reconstruct those keys to recover that data.
1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1).
Existing devices have preferences stored under keys derived from it; slot-based
backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it.
2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes).
Sent to API clients and used as the preference key base on key-lookup backends.
DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm,
the change breaks backward compatibility and will cause data loss.
@@ -124,9 +124,8 @@ def test_entity_object_id_hash_stability(
"""Verify fnv1_hash_object_id produces stable hashes for entity names.
CRITICAL: These expected values MUST NOT CHANGE. Existing devices have
preferences stored under keys derived from this hash, and it is the entity
key sent to API clients; changing it loses stored preferences and breaks
entity state routing.
preferences stored under keys derived from this legacy hash; changing it
breaks the old-to-new key migration and loses stored preferences.
"""
actual = fnv1_hash_object_id(entity_name)
assert actual == expected_object_id_hash, (
@@ -145,8 +144,9 @@ def compute_legacy_preference_key(
) -> int:
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
This is the key EntityBase::make_entity_preference_() (entity_base.cpp)
stores every entity preference under.
This is the key existing devices have data stored under. Slot-based backends
(ESP8266, RP2040) still use it directly; key-lookup backends compute it as the
migration source in EntityBase::make_entity_preference_() (entity_base.cpp).
"""
object_id_hash = fnv1_hash_object_id(entity_name)
preference_hash = object_id_hash ^ device_id
@@ -179,8 +179,8 @@ def test_legacy_preference_key_computation(
) -> None:
"""Verify legacy preference key computation matches expected values.
This test ensures the formula doesn't change, which would lose stored
preferences on every platform.
This test ensures the formula doesn't change, which would break both slot-based
preference storage and the migration source keys on key-lookup backends.
"""
actual_key = compute_legacy_preference_key(entity_name, version, device_id)
@@ -215,12 +215,12 @@ def test_legacy_preference_key_computation(
],
)
def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None:
"""Verify fnv1_hash_name produces stable raw-name hashes.
"""Verify fnv1_hash_name produces stable entity keys.
CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored
preferences under keys derived from this hash; a future key migration must
reconstruct those keys, and changing the algorithm would strand that data.
Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores.
CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to
API clients and is the new preference key base; changing the algorithm
would break state routing and lose stored preferences.
Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h.
"""
actual = fnv1_hash_name(entity_name)
assert actual == expected_key, (