[core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361)

This commit is contained in:
J. Nick Koston
2026-08-13 20:06:48 -05:00
committed by GitHub
parent 45a056e337
commit dd51624fbb
32 changed files with 489 additions and 1132 deletions
+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_entity_key();
msg.key = entity->get_object_id_hash();
#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_entity_key();
msg.key = entity->get_object_id_hash();
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_entity_key();
msg.key = camera::Camera::instance()->get_object_id_hash();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
+6 -2
View File
@@ -154,8 +154,12 @@ 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) {
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
&data.get_raw_data());
#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());
}
#endif
return false; // Don't consume the event, allow other listeners to process it
-63
View File
@@ -63,7 +63,6 @@ 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"]
@@ -333,68 +332,6 @@ 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,7 +3,6 @@ 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"]
@@ -36,11 +35,6 @@ 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,8 +99,12 @@ 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) {
api::global_api_server->send_infrared_rf_receive_event(this->get_device_id_or_zero(), this->get_entity_key(),
&data.get_raw_data());
#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());
}
#endif
return false; // Don't consume the event, allow other listeners to process it
@@ -20,14 +20,18 @@ void TemplateText::setup() {
// Need std::string for pref_->setup() to fill from flash
std::string value{this->initial_value_ != nullptr ? this->initial_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);
// 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);
if (!value.empty())
this->publish_state(value);
}
@@ -14,9 +14,7 @@ class TemplateTextSaverBase {
public:
virtual bool save(const std::string &value) { return true; }
/// 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) {}
virtual void setup(uint32_t id, std::string &value) {}
protected:
ESPPreferenceObject pref_;
@@ -47,16 +45,11 @@ 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, uint32_t old_id, std::string &value) override {
char temp[SZ + 1];
#ifdef USE_PREFERENCE_KEY_LOOKUP
void setup(uint32_t id, std::string &value) override {
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);
char temp[SZ + 1];
bool hasdata = this->pref_.load(&temp);
#endif
if (hasdata) {
size_t len = static_cast<uint8_t>(temp[0]);
+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 entity_key, uint32_t entity_fields) { \
obj->configure_entity_(name, entity_key, entity_fields); \
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); \
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_entity_key() == key && obj->get_device_id() == device_id && \
if (obj->get_object_id_hash() == 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_entity_key() == key && (include_internal || !obj->is_internal())) \
if (obj->get_object_id_hash() == key && (include_internal || !obj->is_internal())) \
return obj; \
} \
return nullptr; \
+20 -32
View File
@@ -8,7 +8,7 @@ namespace esphome {
static const char *const TAG = "entity_base";
void EntityBase::configure_entity_(const char *name, uint32_t entity_key, uint32_t entity_fields) {
void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, 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 entity_key, uint32
}
}
this->flags_.has_own_name = false;
// Dynamic name - must calculate key at runtime
this->calc_entity_key_();
// Dynamic name - must calculate hash at runtime
this->calc_object_id_();
} else {
this->flags_.has_own_name = true;
// Static name - use pre-computed key if provided
if (entity_key != 0) {
this->entity_key_ = entity_key;
// Static name - use pre-computed hash if provided
if (object_id_hash != 0) {
this->object_id_hash_ = object_id_hash;
} else {
this->calc_entity_key_();
this->calc_object_id_();
}
}
// Unpack entity string table indices and flags from entity_fields.
@@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const {
}
#endif // !USE_ESP8266
// 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);
// 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());
}
size_t EntityBase::write_object_id_to(char *buf, size_t buf_size) const {
@@ -173,22 +167,16 @@ 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 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
// 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);
}
#ifdef USE_ENTITY_ICON
+38 -42
View File
@@ -73,17 +73,8 @@ 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 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 the unique Object ID of this Entity
uint32_t get_object_id_hash() const { return this->object_id_hash_; }
/// Get object_id with zero heap allocation
/// For static case: returns StringRef to internal storage (buffer unused)
@@ -190,23 +181,39 @@ class EntityBase {
// Set has_state - for components that need to manually set this
void set_has_state(bool state) { this->flags_.has_state = state; }
/// 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
return this->get_device_id();
#else
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.
/**
* @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.8.0")
uint32_t get_preference_hash() { return this->old_preference_key_base_(); }
"2026.7.0")
uint32_t get_preference_hash() {
#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();
#else
// Without devices, just use object_id_hash as before
return this->get_object_id_hash();
#endif
}
/// Create a preference object for storing this entity's state/settings.
/// @tparam T The type of data to store (must be trivially copyable)
@@ -223,9 +230,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, entity key, entity string indices, and flags.
/// Combined entity setup from codegen: set name, object_id hash, 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 entity_key, uint32_t entity_fields);
void configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields);
#ifdef USE_DEVICES
// Codegen-only setter — only accessible from setup() via friend declaration.
@@ -233,24 +240,13 @@ class EntityBase {
#endif
/// Non-template helper for make_entity_preference() to avoid code bloat.
/// 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
/// When the preference hash algorithm changes, migration logic goes here.
ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version);
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(); }
void calc_object_id_();
StringRef name_;
uint32_t entity_key_{};
uint32_t object_id_hash_{};
#ifdef USE_DEVICES
Device *device_{};
#endif
+79 -111
View File
@@ -25,86 +25,25 @@ from esphome.core.config import (
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_name, sanitize, snake_case
from esphome.helpers import (
cpp_string_escape,
fnv1_hash,
fnv1_hash_object_id,
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_ENTITY_KEY = "_entity_key"
_KEY_OBJECT_ID_HASH = "_entity_object_id_hash"
# Bit layout for entity_fields in configure_entity_().
# Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h
@@ -367,7 +306,7 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None:
standalone ``var->configure_entity_(name, hash, packed)``.
"""
entity_name = config[_KEY_ENTITY_NAME]
entity_key = config[_KEY_ENTITY_KEY]
object_id_hash = config[_KEY_OBJECT_ID_HASH]
dc_idx = config.get(_KEY_DC_IDX, 0)
uom_idx = config.get(_KEY_UOM_IDX, 0)
icon_idx = config.get(_KEY_ICON_IDX, 0)
@@ -387,30 +326,57 @@ 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, entity_key, packed
var, entity_name, object_id_hash, packed
)
else:
expr = var.configure_entity_(entity_name, entity_key, packed)
expr = var.configure_entity_(entity_name, object_id_hash, packed)
if comment:
add(RawStatement(f"{expr}; // {comment}"))
else:
add(expr)
def get_base_entity_name(
def get_base_entity_object_id(
name: str, friendly_name: str | None, device_name: str | None = None
) -> str:
"""Return the base name whose hash becomes this entity's key on the device.
"""Calculate the base object ID for an entity that will be set via set_object_id().
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.
This function calculates what object_id_c_str_ should be set to in C++.
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.
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()
"""
return name or device_name or friendly_name or CORE.name
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))
def setup_entity(var_or_platform, config=None, platform=None):
@@ -469,15 +435,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 entity key for configure_entity_()
# Pre-compute entity name and object_id hash for configure_entity_()
# which is emitted later by finalize_entity_strings().
# 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
# 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)
entity_name = config[CONF_NAME]
entity_key = fnv1_hash_name(entity_name) if entity_name else 0
object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0
config[_KEY_ENTITY_NAME] = entity_name
config[_KEY_ENTITY_KEY] = entity_key
config[_KEY_OBJECT_ID_HASH] = object_id_hash
# Store flags for packing into configure_entity_()
config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT])
if CONF_INTERNAL in config:
@@ -590,13 +556,16 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
# Use the device ID string directly for uniqueness
device_id = device_id_obj.id
# 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)
# 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
)
# 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
# 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)
unique_key = (device_id, platform, name_hash)
if unique_key in CORE.unique_ids:
# Get the existing entity metadata
@@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
if existing_component != "unknown":
conflict_msg += f" from component '{existing_component}'"
# Different names can only clash here through a genuine hash collision
# Distinguish names that sanitize to the same object_id from a genuine
# 32-bit hash collision between two different object_ids
collision_msg = ""
if entity_name != existing_name:
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"
existing_object_id = get_base_entity_object_id(
existing_name, CORE.friendly_name, existing_device or None
)
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
@@ -640,19 +621,6 @@ 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,
+4 -25
View File
@@ -809,19 +809,6 @@ 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>;
@@ -1026,20 +1013,12 @@ 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 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) {
/// 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) {
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,9 +24,10 @@
#endif
// Key-lookup preference backends find stored data by key; their platforms add the
// 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;
// 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;
// migration is not possible there, and key collisions cannot corrupt data.
namespace esphome {
@@ -104,10 +105,9 @@ 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 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.
// 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.
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
@@ -1,25 +0,0 @@
#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,17 +56,5 @@ 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
+5 -10
View File
@@ -91,13 +91,8 @@ 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
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.
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.
"""
return fnv1_hash(sanitize(snake_case(name)))
@@ -105,9 +100,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).
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.
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).
"""
return _fnv1_hash(name.encode("utf-8"))
+13 -14
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case
if TYPE_CHECKING:
from aioesphomeapi import DeviceInfo, EntityInfo
@@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool:
return device_info.name.endswith(f"-{mac_suffix}")
def _resolve_entity_name(
def _get_name_for_object_id(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> str:
"""Resolve the effective name for an entity.
"""Get the name used for object_id computation.
This is the algorithm that aioesphomeapi will use to determine which
name to use for computing object_id client-side from API data; the same
name is what the device hashes into the entity key.
name to use for computing object_id client-side from API data.
Args:
entity: The entity to get name for
@@ -73,27 +72,27 @@ def compute_entity_object_id(
Returns:
The computed object_id string
"""
name = _resolve_entity_name(entity, device_info, device_id_to_name)
return compute_object_id(name)
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
return compute_object_id(name_for_id)
def compute_entity_key(
def compute_entity_hash(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> int:
"""Compute expected entity key for an entity.
"""Compute expected object_id hash for an entity.
Args:
entity: The entity to compute the key for
entity: The entity to compute hash 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 of the raw name
The computed FNV-1 hash
"""
name = _resolve_entity_name(entity, device_info, device_id_to_name)
return fnv1_hash_name(name)
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
return fnv1_hash_object_id(name_for_id)
def verify_entity_object_id(
@@ -119,7 +118,7 @@ def verify_entity_object_id(
f"expected '{expected_object_id}', got '{entity.object_id}'"
)
expected_hash = compute_entity_key(entity, device_info, device_id_to_name)
expected_hash = compute_entity_hash(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,38 +71,6 @@ 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,17 +156,10 @@ 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 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());
// 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());
@@ -1,35 +0,0 @@
esphome:
name: host-pref-key-migration
host:
api:
logger:
switch:
- platform: template
id: test_switch_restore
name: Test Switch
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
number:
- platform: template
id: test_number_restore
name: Test Number
optimistic: true
restore_value: true
initial_value: 1.0
min_value: 0
max_value: 100
step: 0.5
text:
- platform: template
id: test_text_restore
name: Test Text
mode: text
optimistic: true
restore_value: true
initial_value: fallback
min_length: 0
max_length: 20
+7 -17
View File
@@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None:
host_prefs_path(device_name).unlink(missing_ok=True)
def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path:
"""Write preference entries, replacing the file's contents.
Returns the path that was written.
"""
payload = b""
for key, data in entries.items():
if len(data) > 255:
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
payload += struct.pack("<IB", key, len(data)) + data
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
return path
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
"""Write a single preference entry, replacing the file's contents.
Returns the path that was written.
"""
return write_host_prefs(device_name, {key: data})
if len(data) > 255:
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
payload = struct.pack("<IB", key, len(data)) + data
path.write_bytes(payload)
return path
@@ -37,10 +37,6 @@ 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++ 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)
2. C++ hash generation (fnv1_hash_object_id in helpers.h)
3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id)
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_name
from esphome.helpers import fnv1_hash_object_id
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_name(entity_name)
hash_from_name = fnv1_hash_object_id(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_name(expected_name)
expected_hash = fnv1_hash_object_id(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_name
from esphome.helpers import fnv1_hash_object_id
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_name("My Friendly Device")
expected_hash = fnv1_hash_object_id("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_name
from esphome.helpers import fnv1_hash_object_id
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_name() with fallback to CORE.name
- Python used get_base_entity_object_id() 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_name("test-device")
expected_hash = fnv1_hash_object_id("test-device")
assert entity.key == expected_hash, (
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
)
@@ -1,165 +0,0 @@
"""Integration test for entity preference key migration.
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.
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
import socket
import struct
from aioesphomeapi import (
NumberInfo,
NumberState,
SwitchInfo,
SwitchState,
TextInfo,
TextState,
)
import pytest
from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
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-migration"
# 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_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
def text_pref_payload(value: str) -> bytes:
"""Build the length-prefixed buffer TextSaver stores for a value."""
data = value.encode("utf-8")
assert len(data) <= TEXT_MAX_LENGTH
return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data))
@pytest.mark.asyncio
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 under the old key survive the upgrade."""
port, port_socket = reserved_tcp_port
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)
binary_path = await compile_esphome(config_path)
# Release the reserved port so the binary can bind to it
port_socket.close()
async def boot_and_get_initial_states() -> tuple[
SwitchState, NumberState, TextState
]:
"""Boot the binary and return the restored entity states."""
async with (
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
wait_and_connect_api_client(port=port) as client,
):
device_info = await client.device_info()
assert device_info.name == DEVICE_NAME
entities, _ = await client.list_entities_services()
switch_entity = require_entity(
entities, "test_switch", SwitchInfo, "Test Switch"
)
number_entity = require_entity(
entities, "test_number", NumberInfo, "Test Number"
)
text_entity = require_entity(entities, "test_text", TextInfo, "Test Text")
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(
initial_state_helper.on_state_wrapper(lambda s: None)
)
await initial_state_helper.wait_for_initial_states()
switch_state = initial_state_helper.initial_states[switch_entity.key]
number_state = initial_state_helper.initial_states[number_entity.key]
text_state = initial_state_helper.initial_states[text_entity.key]
assert isinstance(switch_state, SwitchState)
assert isinstance(number_state, NumberState)
assert isinstance(text_state, TextState)
return switch_state, number_state, text_state
try:
# --- 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_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 old preference key was lost"
)
assert number_state.state == 42.5, (
"Number value stored under the old preference key was lost"
)
assert text_state.state == "hello", (
"Text value stored under the old preference key was lost"
)
# --- 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_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, (
"Stale old-key data overwrote the current new-key switch state"
)
assert number_state.state == 13.5, (
"Stale old-key data overwrote the current new-key number value"
)
assert text_state.state == "world", (
"Stale old-key data overwrote the current new-key text value"
)
finally:
clear_host_prefs(DEVICE_NAME)
@@ -1,239 +0,0 @@
"""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: ""})
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
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;
@@ -1,15 +0,0 @@
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;
@@ -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 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.
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.
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,8 +124,9 @@ 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 legacy hash; changing it
breaks the old-to-new key migration and loses stored preferences.
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.
"""
actual = fnv1_hash_object_id(entity_name)
assert actual == expected_object_id_hash, (
@@ -144,9 +145,8 @@ def compute_legacy_preference_key(
) -> int:
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
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).
This is the key EntityBase::make_entity_preference_() (entity_base.cpp)
stores every entity preference under.
"""
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 break both slot-based
preference storage and the migration source keys on key-lookup backends.
This test ensures the formula doesn't change, which would lose stored
preferences on every platform.
"""
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 entity keys.
"""Verify fnv1_hash_name produces stable raw-name hashes.
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.
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.
"""
actual = fnv1_hash_name(entity_name)
assert actual == expected_key, (