mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
[core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361)
This commit is contained in:
@@ -448,7 +448,7 @@ void APIConnection::on_disconnect_response() {
|
|||||||
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
|
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
|
||||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
||||||
APIConnection *conn, uint32_t remaining_size) {
|
APIConnection *conn, uint32_t remaining_size) {
|
||||||
msg.key = entity->get_entity_key();
|
msg.key = entity->get_object_id_hash();
|
||||||
#ifdef USE_DEVICES
|
#ifdef USE_DEVICES
|
||||||
msg.device_id = entity->get_device_id();
|
msg.device_id = entity->get_device_id();
|
||||||
#endif
|
#endif
|
||||||
@@ -459,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
|
|||||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
||||||
APIConnection *conn, uint32_t remaining_size) {
|
APIConnection *conn, uint32_t remaining_size) {
|
||||||
// Set common fields that are shared by all entity types
|
// 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()) {
|
if (entity->has_own_name()) {
|
||||||
msg.name = entity->get_name();
|
msg.name = entity->get_name();
|
||||||
@@ -1149,7 +1149,7 @@ void APIConnection::try_send_camera_image_() {
|
|||||||
bool done = this->image_reader_->available() == to_send;
|
bool done = this->image_reader_->available() == to_send;
|
||||||
|
|
||||||
CameraImageResponse msg;
|
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.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
||||||
msg.done = done;
|
msg.done = done;
|
||||||
#ifdef USE_DEVICES
|
#ifdef USE_DEVICES
|
||||||
|
|||||||
@@ -154,8 +154,12 @@ bool Infrared::on_receive(remote_base::RemoteReceiveData data) {
|
|||||||
// Forward received IR data to API server
|
// Forward received IR data to API server
|
||||||
#if defined(USE_API) && defined(USE_IR_RF)
|
#if defined(USE_API) && defined(USE_IR_RF)
|
||||||
if (api::global_api_server != nullptr) {
|
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(),
|
#ifdef USE_DEVICES
|
||||||
&data.get_raw_data());
|
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
|
#endif
|
||||||
return false; // Don't consume the event, allow other listeners to process it
|
return false; // Don't consume the event, allow other listeners to process it
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ from esphome.const import (
|
|||||||
PlatformFramework,
|
PlatformFramework,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
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
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
DEPENDENCIES = ["network"]
|
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):
|
def exp_mqtt_message(config):
|
||||||
if config is None:
|
if config is None:
|
||||||
return cg.optional(cg.TemplateArguments(MQTTMessage))
|
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
|
from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID
|
||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import CONF_ID, CONF_INCLUDE_INTERNAL, CONF_NAME, CONF_RELABEL
|
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
|
from esphome.cpp_types import EntityBase
|
||||||
|
|
||||||
AUTO_LOAD = ["web_server_base"]
|
AUTO_LOAD = ["web_server_base"]
|
||||||
@@ -36,11 +35,6 @@ CONFIG_SCHEMA = cv.Schema(
|
|||||||
},
|
},
|
||||||
).extend(cv.COMPONENT_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):
|
async def to_code(config):
|
||||||
paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID])
|
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
|
// Forward received RF data to API server
|
||||||
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
|
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
|
||||||
if (api::global_api_server != nullptr) {
|
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(),
|
#ifdef USE_DEVICES
|
||||||
&data.get_raw_data());
|
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
|
#endif
|
||||||
return false; // Don't consume the event, allow other listeners to process it
|
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
|
// Need std::string for pref_->setup() to fill from flash
|
||||||
std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""};
|
std::string value{this->initial_value_ != nullptr ? this->initial_value_ : ""};
|
||||||
uint32_t extra = 0;
|
// For future hash migration: use migrate_entity_preference_() with:
|
||||||
extra += this->traits.get_min_length() << 2;
|
// old_key = get_preference_hash() + extra
|
||||||
extra += this->traits.get_max_length() << 4;
|
// new_key = get_preference_hash_v2() + extra
|
||||||
extra += fnv1_hash(this->traits.get_pattern_c_str()) << 6;
|
// See: https://github.com/esphome/backlog/issues/85
|
||||||
// TextSaver::setup() picks the key for the platform and migrates old data once
|
#pragma GCC diagnostic push
|
||||||
uint32_t key = this->preference_key_base_() + extra;
|
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||||
uint32_t old_key = this->old_preference_key_base_() + extra;
|
uint32_t key = this->get_preference_hash();
|
||||||
this->pref_->setup(key, old_key, value);
|
#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())
|
if (!value.empty())
|
||||||
this->publish_state(value);
|
this->publish_state(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ class TemplateTextSaverBase {
|
|||||||
public:
|
public:
|
||||||
virtual bool save(const std::string &value) { return true; }
|
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.
|
virtual void setup(uint32_t id, std::string &value) {}
|
||||||
/// See: https://github.com/esphome/backlog/issues/85
|
|
||||||
virtual void setup(uint32_t id, uint32_t old_id, std::string &value) {}
|
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
ESPPreferenceObject pref_;
|
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
|
// Make the preference object. Fill the provided location with the saved data
|
||||||
// If it is available, else leave it alone
|
// If it is available, else leave it alone
|
||||||
void setup(uint32_t id, uint32_t old_id, std::string &value) override {
|
void setup(uint32_t 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);
|
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
|
char temp[SZ + 1];
|
||||||
// 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);
|
bool hasdata = this->pref_.load(&temp);
|
||||||
#endif
|
|
||||||
|
|
||||||
if (hasdata) {
|
if (hasdata) {
|
||||||
size_t len = static_cast<uint8_t>(temp[0]);
|
size_t len = static_cast<uint8_t>(temp[0]);
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ class Application {
|
|||||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||||
void register_##singular(type *obj) { this->plural##_.push_back(obj); } \
|
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) { \
|
void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \
|
||||||
obj->configure_entity_(name, entity_key, entity_fields); \
|
obj->configure_entity_(name, object_id_hash, entity_fields); \
|
||||||
this->plural##_.push_back(obj); \
|
this->plural##_.push_back(obj); \
|
||||||
}
|
}
|
||||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
#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) \
|
#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) { \
|
entity_type *get_##entity_name##_by_key(uint32_t key, uint32_t device_id, bool include_internal = false) { \
|
||||||
for (auto *obj : this->entities_member##_) { \
|
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())) \
|
(include_internal || !obj->is_internal())) \
|
||||||
return obj; \
|
return obj; \
|
||||||
} \
|
} \
|
||||||
@@ -340,7 +340,7 @@ class Application {
|
|||||||
#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \
|
#define GET_ENTITY_METHOD(entity_type, entity_name, entities_member) \
|
||||||
entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \
|
entity_type *get_##entity_name##_by_key(uint32_t key, bool include_internal = false) { \
|
||||||
for (auto *obj : this->entities_member##_) { \
|
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 obj; \
|
||||||
} \
|
} \
|
||||||
return nullptr; \
|
return nullptr; \
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace esphome {
|
|||||||
|
|
||||||
static const char *const TAG = "entity_base";
|
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);
|
this->name_ = StringRef(name);
|
||||||
if (this->name_.empty()) {
|
if (this->name_.empty()) {
|
||||||
#ifdef USE_DEVICES
|
#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;
|
this->flags_.has_own_name = false;
|
||||||
// Dynamic name - must calculate key at runtime
|
// Dynamic name - must calculate hash at runtime
|
||||||
this->calc_entity_key_();
|
this->calc_object_id_();
|
||||||
} else {
|
} else {
|
||||||
this->flags_.has_own_name = true;
|
this->flags_.has_own_name = true;
|
||||||
// Static name - use pre-computed key if provided
|
// Static name - use pre-computed hash if provided
|
||||||
if (entity_key != 0) {
|
if (object_id_hash != 0) {
|
||||||
this->entity_key_ = entity_key;
|
this->object_id_hash_ = object_id_hash;
|
||||||
} else {
|
} else {
|
||||||
this->calc_entity_key_();
|
this->calc_object_id_();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Unpack entity string table indices and flags from entity_fields.
|
// Unpack entity string table indices and flags from entity_fields.
|
||||||
@@ -147,15 +147,9 @@ std::string EntityBase::get_icon() const {
|
|||||||
}
|
}
|
||||||
#endif // !USE_ESP8266
|
#endif // !USE_ESP8266
|
||||||
|
|
||||||
// Calculate the entity key directly from the raw name (no transformations)
|
// Calculate Object ID Hash directly from name using snake_case + sanitize
|
||||||
void EntityBase::calc_entity_key_() { this->entity_key_ = fnv1_hash_bytes(this->name_.c_str(), this->name_.size()); }
|
void EntityBase::calc_object_id_() {
|
||||||
|
this->object_id_hash_ = fnv1_hash_object_id(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 {
|
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) {
|
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
|
// The key hashes the sanitized object_id, so multiple entity names can collide on one
|
||||||
// one key and overwrite each other's stored preferences; the new key hashes the raw name.
|
// key and overwrite each other's stored preferences ("Living Room" and "living_room",
|
||||||
// See: https://github.com/esphome/backlog/issues/85
|
// or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name
|
||||||
uint32_t old_key = this->old_preference_key_base_() ^ version;
|
// fix this, but they change the entity key API clients track, which the Home Assistant
|
||||||
#ifdef USE_PREFERENCE_KEY_LOOKUP
|
// esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85
|
||||||
uint32_t new_key = this->preference_key_base_() ^ version;
|
#pragma GCC diagnostic push
|
||||||
auto pref = global_preferences->make_preference(size, new_key);
|
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||||
// All in-tree entity preferences fit the stack buffer, so migration never hits the heap
|
uint32_t key = this->get_preference_hash() ^ version;
|
||||||
SmallBufferWithHeapFallback<64> buffer(size);
|
#pragma GCC diagnostic pop
|
||||||
migrate_preference(pref, buffer.get(), size, old_key, new_key);
|
return global_preferences->make_preference(size, 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
|
#ifdef USE_ENTITY_ICON
|
||||||
|
|||||||
+38
-42
@@ -73,17 +73,8 @@ class EntityBase {
|
|||||||
// Get whether this Entity has its own name or it should use the device friendly_name.
|
// 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; }
|
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.
|
// Get the unique Object ID of this Entity
|
||||||
// This is the key sent to API clients and used to route entity state.
|
uint32_t get_object_id_hash() const { return this->object_id_hash_; }
|
||||||
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
|
/// Get object_id with zero heap allocation
|
||||||
/// For static case: returns StringRef to internal storage (buffer unused)
|
/// 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
|
// Set has_state - for components that need to manually set this
|
||||||
void set_has_state(bool state) { this->flags_.has_state = state; }
|
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 {
|
* @brief Get a unique hash for storing preferences/settings for this entity.
|
||||||
#ifdef USE_DEVICES
|
*
|
||||||
return this->get_device_id();
|
* This method returns a hash that uniquely identifies the entity for the purpose of
|
||||||
#else
|
* storing preferences (such as calibration, state, etc.). Unlike get_object_id_hash(),
|
||||||
return 0;
|
* this hash also incorporates the device_id (if devices are enabled), ensuring uniqueness
|
||||||
#endif
|
* 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
|
||||||
/// Get the LEGACY preference key: FNV-1 hash of the sanitized object_id, XOR device_id.
|
* per device-entity pair. Use get_object_id_hash() when you need a hash that identifies
|
||||||
/// Intentionally keeps the old algorithm so external callers that store preferences under
|
* the entity regardless of the device it belongs to.
|
||||||
/// this key keep stable keys; make_entity_preference() migrates to the new raw-name key,
|
*
|
||||||
/// this method never will.
|
* 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. "
|
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.",
|
"See https://github.com/esphome/backlog/issues/85. Will be removed in 2027.1.0.",
|
||||||
"2026.8.0")
|
"2026.7.0")
|
||||||
uint32_t get_preference_hash() { return this->old_preference_key_base_(); }
|
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.
|
/// Create a preference object for storing this entity's state/settings.
|
||||||
/// @tparam T The type of data to store (must be trivially copyable)
|
/// @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.
|
// before push_back, so codegen can emit a single combined call per entity.
|
||||||
friend class Application;
|
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.
|
/// 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
|
#ifdef USE_DEVICES
|
||||||
// Codegen-only setter — only accessible from setup() via friend declaration.
|
// Codegen-only setter — only accessible from setup() via friend declaration.
|
||||||
@@ -233,24 +240,13 @@ class EntityBase {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Non-template helper for make_entity_preference() to avoid code bloat.
|
/// 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
|
/// When the preference hash algorithm changes, migration logic goes here.
|
||||||
/// on key-lookup platforms. See: https://github.com/esphome/backlog/issues/85
|
|
||||||
ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version);
|
ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version);
|
||||||
|
|
||||||
void calc_entity_key_();
|
void calc_object_id_();
|
||||||
|
|
||||||
/// 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_;
|
StringRef name_;
|
||||||
uint32_t entity_key_{};
|
uint32_t object_id_hash_{};
|
||||||
#ifdef USE_DEVICES
|
#ifdef USE_DEVICES
|
||||||
Device *device_{};
|
Device *device_{};
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+79
-111
@@ -25,86 +25,25 @@ from esphome.core.config import (
|
|||||||
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
|
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
|
||||||
from esphome.cpp_types import App
|
from esphome.cpp_types import App
|
||||||
import esphome.final_validate as fv
|
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
|
from esphome.types import ConfigType, EntityMetadata
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DOMAIN = "entity_string_pool"
|
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
|
# Private config keys for storing registered string indices
|
||||||
_KEY_DC_IDX = "_entity_dc_idx"
|
_KEY_DC_IDX = "_entity_dc_idx"
|
||||||
_KEY_UOM_IDX = "_entity_uom_idx"
|
_KEY_UOM_IDX = "_entity_uom_idx"
|
||||||
_KEY_ICON_IDX = "_entity_icon_idx"
|
_KEY_ICON_IDX = "_entity_icon_idx"
|
||||||
_KEY_ENTITY_NAME = "_entity_name"
|
_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_().
|
# Bit layout for entity_fields in configure_entity_().
|
||||||
# Keep in sync with ENTITY_FIELD_*_SHIFT constants in esphome/core/entity_base.h
|
# 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)``.
|
standalone ``var->configure_entity_(name, hash, packed)``.
|
||||||
"""
|
"""
|
||||||
entity_name = config[_KEY_ENTITY_NAME]
|
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)
|
dc_idx = config.get(_KEY_DC_IDX, 0)
|
||||||
uom_idx = config.get(_KEY_UOM_IDX, 0)
|
uom_idx = config.get(_KEY_UOM_IDX, 0)
|
||||||
icon_idx = config.get(_KEY_ICON_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)
|
register_method = config.get(_KEY_REGISTER_METHOD)
|
||||||
if register_method is not None:
|
if register_method is not None:
|
||||||
expr = getattr(App, f"register_{register_method}")(
|
expr = getattr(App, f"register_{register_method}")(
|
||||||
var, entity_name, entity_key, packed
|
var, entity_name, object_id_hash, packed
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
expr = var.configure_entity_(entity_name, entity_key, packed)
|
expr = var.configure_entity_(entity_name, object_id_hash, packed)
|
||||||
if comment:
|
if comment:
|
||||||
add(RawStatement(f"{expr}; // {comment}"))
|
add(RawStatement(f"{expr}; // {comment}"))
|
||||||
else:
|
else:
|
||||||
add(expr)
|
add(expr)
|
||||||
|
|
||||||
|
|
||||||
def get_base_entity_name(
|
def get_base_entity_object_id(
|
||||||
name: str, friendly_name: str | None, device_name: str | None = None
|
name: str, friendly_name: str | None, device_name: str | None = None
|
||||||
) -> str:
|
) -> 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):
|
This function calculates what object_id_c_str_ should be set to in C++.
|
||||||
entity name, then sub-device name, then friendly name, then the device name.
|
|
||||||
|
|
||||||
This is a config-time approximation for duplicate checking: when
|
The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as:
|
||||||
name_add_mac_suffix is enabled the device appends the MAC suffix at runtime,
|
- If !has_own_name && is_name_add_mac_suffix_enabled():
|
||||||
which is unknown here and identical for every entity on the device, so
|
return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic
|
||||||
ignoring it cannot change whether two entities collide with each other.
|
- 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):
|
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)
|
device: MockObj = await get_variable(device_id_obj)
|
||||||
add(var.set_device_(device))
|
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().
|
# which is emitted later by finalize_entity_strings().
|
||||||
# For named entities: pre-compute the key from the raw entity name
|
# For named entities: pre-compute hash from entity name
|
||||||
# For empty-name entities: pass 0, C++ calculates the key at runtime from
|
# For empty-name entities: pass 0, C++ calculates hash at runtime from
|
||||||
# device name, friendly_name, or app name
|
# device name, friendly_name, or app name (bug-for-bug compatibility)
|
||||||
entity_name = config[CONF_NAME]
|
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_NAME] = entity_name
|
||||||
config[_KEY_ENTITY_KEY] = entity_key
|
config[_KEY_OBJECT_ID_HASH] = object_id_hash
|
||||||
# Store flags for packing into configure_entity_()
|
# Store flags for packing into configure_entity_()
|
||||||
config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT])
|
config[_KEY_DISABLED_BY_DEFAULT] = int(config[CONF_DISABLED_BY_DEFAULT])
|
||||||
if CONF_INTERNAL in config:
|
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
|
# Use the device ID string directly for uniqueness
|
||||||
device_id = device_id_obj.id
|
device_id = device_id_obj.id
|
||||||
|
|
||||||
# Hash the same raw name the device hashes into the entity key at runtime.
|
# Calculate what object_id will actually be used
|
||||||
# This handles empty names correctly by using device/friendly names.
|
# This handles empty names correctly by using device/friendly names
|
||||||
base_name = get_base_entity_name(entity_name, CORE.friendly_name, device_name)
|
name_key = get_base_entity_object_id(
|
||||||
name_hash = fnv1_hash_name(base_name)
|
entity_name, CORE.friendly_name, device_name
|
||||||
|
)
|
||||||
|
|
||||||
# Check for duplicates: two entities on the same device and platform must not
|
# Check for duplicates by the FNV-1 hash of the object_id, which is the entity
|
||||||
# share an entity key, since the key is what routes state to API clients
|
# 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)
|
unique_key = (device_id, platform, name_hash)
|
||||||
if unique_key in CORE.unique_ids:
|
if unique_key in CORE.unique_ids:
|
||||||
# Get the existing entity metadata
|
# Get the existing entity metadata
|
||||||
@@ -621,14 +590,26 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
|
|||||||
if existing_component != "unknown":
|
if existing_component != "unknown":
|
||||||
conflict_msg += f" from component '{existing_component}'"
|
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 = ""
|
collision_msg = ""
|
||||||
if entity_name != existing_name:
|
if entity_name != existing_name:
|
||||||
collision_msg = (
|
existing_object_id = get_base_entity_object_id(
|
||||||
f"\n The names '{entity_name}' and '{existing_name}' produce the"
|
existing_name, CORE.friendly_name, existing_device or None
|
||||||
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
|
# Skip duplicate entity name validation when testing_mode is enabled
|
||||||
# This flag is used for grouped component testing
|
# This flag is used for grouped component testing
|
||||||
@@ -640,19 +621,6 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
|
|||||||
f"{collision_msg}"
|
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
|
# Store metadata about this entity
|
||||||
entity_metadata: EntityMetadata = {
|
entity_metadata: EntityMetadata = {
|
||||||
"name": entity_name,
|
"name": entity_name,
|
||||||
|
|||||||
+4
-25
@@ -809,19 +809,6 @@ constexpr uint32_t FNV1_OFFSET_BASIS = 2166136261UL;
|
|||||||
/// FNV-1 32-bit prime
|
/// FNV-1 32-bit prime
|
||||||
constexpr uint32_t FNV1_PRIME = 16777619UL;
|
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).
|
/// 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) {
|
template<std::integral T> constexpr uint32_t fnv1_hash_extend(uint32_t hash, T value) {
|
||||||
using UnsignedT = std::make_unsigned_t<T>;
|
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
|
// 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.
|
/// 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
|
/// This computes object_id hashes directly from names without creating an intermediate buffer.
|
||||||
/// devices already have stored; see https://github.com/esphome/backlog/issues/85.
|
/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py.
|
||||||
/// With per_code_point set, UTF-8 continuation bytes are skipped so each multi-byte character
|
/// If you modify this function, update the Python version and tests in both places.
|
||||||
/// contributes one underscore — this matches Python fnv1_hash_object_id() in esphome/helpers.py,
|
inline uint32_t fnv1_hash_object_id(const char *str, size_t len) {
|
||||||
/// 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;
|
uint32_t hash = FNV1_OFFSET_BASIS;
|
||||||
for (size_t i = 0; i < len; i++) {
|
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;
|
hash *= FNV1_PRIME;
|
||||||
// Apply snake_case (space->underscore, uppercase->lowercase) then sanitize
|
// Apply snake_case (space->underscore, uppercase->lowercase) then sanitize
|
||||||
hash ^= static_cast<uint8_t>(to_sanitized_char(to_snake_case_char(str[i])));
|
hash ^= static_cast<uint8_t>(to_sanitized_char(to_snake_case_char(str[i])));
|
||||||
|
|||||||
@@ -24,9 +24,10 @@
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Key-lookup preference backends find stored data by key; their platforms add the
|
// 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
|
// USE_PREFERENCE_KEY_LOOKUP define from Python codegen, which enables one-shot reads
|
||||||
// migration. Slot-based backends (ESP8266, RP2040) instead allocate a storage slot for
|
// of stored data by key (the primitive preference key migrations need). Slot-based
|
||||||
// every make_preference() call and use the key only as a validity tag on that slot;
|
// 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.
|
// migration is not possible there, and key collisions cannot corrupt data.
|
||||||
|
|
||||||
namespace esphome {
|
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
|
// 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
|
// of a stored preference by key; see the key-lookup note at the top of this
|
||||||
// key-lookup note at the top of this file. Not part of PreferencesContract,
|
// file. Not part of PreferencesContract, so it is asserted in preferences.h
|
||||||
// so it is asserted in preferences.h only where USE_PREFERENCE_KEY_LOOKUP
|
// only where USE_PREFERENCE_KEY_LOOKUP is set.
|
||||||
// is set.
|
|
||||||
template<typename T>
|
template<typename T>
|
||||||
concept PreferencesKeyLookupContract = requires(T prefs, uint32_t type, uint8_t *data, size_t len) {
|
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>;
|
{ prefs.load_from_key(type, data, len) } -> std::same_as<bool>;
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -56,17 +56,5 @@ namespace esphome {
|
|||||||
static_assert(PreferencesKeyLookupContract<ESPPreferences>,
|
static_assert(PreferencesKeyLookupContract<ESPPreferences>,
|
||||||
"This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide "
|
"This platform emits USE_PREFERENCE_KEY_LOOKUP but its preferences manager does not provide "
|
||||||
"load_from_key() (esphome/core/preference_backend.h)");
|
"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
|
} // namespace esphome
|
||||||
#endif // USE_PREFERENCE_KEY_LOOKUP
|
#endif // USE_PREFERENCE_KEY_LOOKUP
|
||||||
|
|||||||
+5
-10
@@ -91,13 +91,8 @@ def fnv1a_32bit_hash(string: str) -> int:
|
|||||||
def fnv1_hash_object_id(name: str) -> int:
|
def fnv1_hash_object_id(name: str) -> int:
|
||||||
"""Compute FNV-1 hash of name with snake_case + sanitize transformations.
|
"""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
|
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
|
If you modify this function, update the C++ version and tests in both places.
|
||||||
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)))
|
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:
|
def fnv1_hash_name(name: str) -> int:
|
||||||
"""Compute FNV-1 hash of the raw entity name (UTF-8 bytes, no transformations).
|
"""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,
|
2026.8 beta firmware stored preferences under keys derived from this hash;
|
||||||
which hashes the name bytes as stored on the device.
|
a future key migration must reconstruct those keys to recover that data
|
||||||
Used for pre-computing entity keys at code generation time.
|
(see https://github.com/esphome/backlog/issues/85).
|
||||||
"""
|
"""
|
||||||
return _fnv1_hash(name.encode("utf-8"))
|
return _fnv1_hash(name.encode("utf-8"))
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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:
|
if TYPE_CHECKING:
|
||||||
from aioesphomeapi import DeviceInfo, EntityInfo
|
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}")
|
return device_info.name.endswith(f"-{mac_suffix}")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_entity_name(
|
def _get_name_for_object_id(
|
||||||
entity: EntityInfo,
|
entity: EntityInfo,
|
||||||
device_info: DeviceInfo,
|
device_info: DeviceInfo,
|
||||||
device_id_to_name: dict[int, str],
|
device_id_to_name: dict[int, str],
|
||||||
) -> 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
|
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 to use for computing object_id client-side from API data.
|
||||||
name is what the device hashes into the entity key.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entity: The entity to get name for
|
entity: The entity to get name for
|
||||||
@@ -73,27 +72,27 @@ def compute_entity_object_id(
|
|||||||
Returns:
|
Returns:
|
||||||
The computed object_id string
|
The computed object_id string
|
||||||
"""
|
"""
|
||||||
name = _resolve_entity_name(entity, device_info, device_id_to_name)
|
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
|
||||||
return compute_object_id(name)
|
return compute_object_id(name_for_id)
|
||||||
|
|
||||||
|
|
||||||
def compute_entity_key(
|
def compute_entity_hash(
|
||||||
entity: EntityInfo,
|
entity: EntityInfo,
|
||||||
device_info: DeviceInfo,
|
device_info: DeviceInfo,
|
||||||
device_id_to_name: dict[int, str],
|
device_id_to_name: dict[int, str],
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Compute expected entity key for an entity.
|
"""Compute expected object_id hash for an entity.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entity: The entity to compute the key for
|
entity: The entity to compute hash for
|
||||||
device_info: Device info from the API
|
device_info: Device info from the API
|
||||||
device_id_to_name: Mapping of device_id to device name for sub-devices
|
device_id_to_name: Mapping of device_id to device name for sub-devices
|
||||||
|
|
||||||
Returns:
|
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)
|
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
|
||||||
return fnv1_hash_name(name)
|
return fnv1_hash_object_id(name_for_id)
|
||||||
|
|
||||||
|
|
||||||
def verify_entity_object_id(
|
def verify_entity_object_id(
|
||||||
@@ -119,7 +118,7 @@ def verify_entity_object_id(
|
|||||||
f"expected '{expected_object_id}', got '{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, (
|
assert entity.key == expected_hash, (
|
||||||
f"hash mismatch for entity '{entity.name}': "
|
f"hash mismatch for entity '{entity.name}': "
|
||||||
f"expected {expected_hash:#x}, got {entity.key:#x}"
|
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);
|
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:
|
host:
|
||||||
api:
|
api:
|
||||||
logger:
|
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 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", "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());
|
ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str());
|
||||||
// Log preference key bases for entities that actually store preferences.
|
// Log preference hashes 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_preference_hash());
|
||||||
ESP_LOGI("test", "Device A Switch Pref Hash: %u",
|
ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash());
|
||||||
id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero());
|
ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash());
|
||||||
ESP_LOGI("test", "Device B Switch Pref Hash: %u",
|
ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash());
|
||||||
id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero());
|
ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash());
|
||||||
ESP_LOGI("test", "Main Switch Pref Hash: %u",
|
ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash());
|
||||||
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,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
|
|
||||||
@@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None:
|
|||||||
host_prefs_path(device_name).unlink(missing_ok=True)
|
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:
|
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
|
||||||
"""Write a single preference entry, replacing the file's contents.
|
"""Write a single preference entry, replacing the file's contents.
|
||||||
|
|
||||||
Returns the path that was written.
|
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",
|
"special",
|
||||||
"complex",
|
"complex",
|
||||||
"empty",
|
"empty",
|
||||||
"raw",
|
|
||||||
"raw_utf8",
|
|
||||||
"old_utf8",
|
|
||||||
"old_cjk",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def on_log_line(line: str) -> None:
|
def on_log_line(line: str) -> None:
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
This test verifies a three-way match between:
|
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)
|
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)
|
2. C++ hash generation (fnv1_hash_object_id in helpers.h)
|
||||||
3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py)
|
3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id)
|
||||||
|
|
||||||
The API response contains C++ computed values, so verifying API == Python
|
The API response contains C++ computed values, so verifying API == Python
|
||||||
implicitly verifies C++ == Python == API for both object_id and hash.
|
implicitly verifies C++ == Python == API for both object_id and hash.
|
||||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
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 .entity_utils import compute_object_id, verify_all_entities
|
||||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||||
@@ -123,7 +123,7 @@ async def test_object_id_api_verification(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify hash can be computed from the name
|
# 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, (
|
assert hash_from_name == entity.key, (
|
||||||
f"Entity '{entity_name}': hash mismatch. "
|
f"Entity '{entity_name}': hash mismatch. "
|
||||||
f"Python hash {hash_from_name:#x}, API key {entity.key:#x}"
|
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
|
# Verify hash matches
|
||||||
expected_hash = fnv1_hash_name(expected_name)
|
expected_hash = fnv1_hash_object_id(expected_name)
|
||||||
assert entity.key == expected_hash, (
|
assert entity.key == expected_hash, (
|
||||||
f"Empty-name entity (device_id={entity.device_id}): hash mismatch. "
|
f"Empty-name entity (device_id={entity.device_id}): hash mismatch. "
|
||||||
f"API key: {entity.key:#x}, expected: {expected_hash:#x}"
|
f"API key: {entity.key:#x}, expected: {expected_hash:#x}"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from esphome.helpers import fnv1_hash_name
|
from esphome.helpers import fnv1_hash_object_id
|
||||||
|
|
||||||
from .entity_utils import (
|
from .entity_utils import (
|
||||||
compute_object_id,
|
compute_object_id,
|
||||||
@@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Hash should match friendly_name
|
# 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, (
|
assert entity.key == expected_hash, (
|
||||||
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
|
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
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 .entity_utils import compute_object_id, verify_all_entities
|
||||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||||
@@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix(
|
|||||||
OLD behavior:
|
OLD behavior:
|
||||||
- is_object_id_dynamic_() returned false (mac suffix not enabled)
|
- is_object_id_dynamic_() returned false (mac suffix not enabled)
|
||||||
- Used object_id_c_str_ which was pre-computed in Python
|
- 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))
|
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
|
# 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, (
|
assert entity.key == expected_hash, (
|
||||||
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
|
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: ""})
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for entity helpers: name selection, entity key hashing, duplicate checks."""
|
"""Test get_base_entity_object_id function matches C++ behavior."""
|
||||||
|
|
||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable, Generator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -25,17 +25,16 @@ from esphome.core.entity_helpers import (
|
|||||||
_setup_entity_impl,
|
_setup_entity_impl,
|
||||||
entity_duplicate_validator,
|
entity_duplicate_validator,
|
||||||
finalize_entity_strings,
|
finalize_entity_strings,
|
||||||
get_base_entity_name,
|
get_base_entity_object_id,
|
||||||
register_device_class,
|
register_device_class,
|
||||||
register_icon,
|
register_icon,
|
||||||
register_unit_of_measurement,
|
register_unit_of_measurement,
|
||||||
setup_device_class,
|
setup_device_class,
|
||||||
setup_entity,
|
setup_entity,
|
||||||
setup_unit_of_measurement,
|
setup_unit_of_measurement,
|
||||||
validate_no_object_id_conflicts,
|
|
||||||
)
|
)
|
||||||
from esphome.cpp_generator import MockObj
|
from esphome.cpp_generator import MockObj
|
||||||
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
|
from esphome.helpers import fnv1_hash, sanitize, snake_case
|
||||||
|
|
||||||
from .common import load_config_from_fixture
|
from .common import load_config_from_fixture
|
||||||
|
|
||||||
@@ -58,26 +57,206 @@ def restore_core_state() -> Generator[None, None, None]:
|
|||||||
CORE.friendly_name = original_friendly_name
|
CORE.friendly_name = original_friendly_name
|
||||||
|
|
||||||
|
|
||||||
def test_get_base_entity_name_priority_order() -> None:
|
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:
|
||||||
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
|
"""Test the priority order: entity name > device name > friendly name > CORE.name."""
|
||||||
CORE.name = "core-device"
|
CORE.name = "core-device"
|
||||||
|
|
||||||
# 1. Entity name has highest priority and is used as-is, no transformations
|
# 1. Entity name has highest priority
|
||||||
assert (
|
assert (
|
||||||
get_base_entity_name("Entity Name", "Friendly Name", "Device Name")
|
get_base_entity_object_id("Entity Name", "Friendly Name", "Device Name")
|
||||||
== "Entity Name"
|
== "entity_name"
|
||||||
)
|
)
|
||||||
assert get_base_entity_name("Température", None) == "Température"
|
|
||||||
|
|
||||||
# 2. Device name is next priority (when entity name is empty)
|
# 2. Device name is next priority (when entity name is empty)
|
||||||
assert get_base_entity_name("", "Friendly Name", "Device Name") == "Device Name"
|
assert (
|
||||||
|
get_base_entity_object_id("", "Friendly Name", "Device Name") == "device_name"
|
||||||
|
)
|
||||||
|
|
||||||
# 3. Friendly name is next (when entity and device names are empty)
|
# 3. Friendly name is next (when entity and device names are empty)
|
||||||
assert get_base_entity_name("", "Friendly Name", None) == "Friendly Name"
|
assert get_base_entity_object_id("", "Friendly Name", None) == "friendly_name"
|
||||||
|
|
||||||
# 4. CORE.name is last resort; an empty friendly name falls through to it
|
# 4. CORE.name is last resort
|
||||||
assert get_base_entity_name("", None, None) == "core-device"
|
assert get_base_entity_object_id("", None, None) == "core-device"
|
||||||
assert get_base_entity_name("", "") == "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"
|
||||||
|
|
||||||
|
|
||||||
# Tests for setup_entity function
|
# Tests for setup_entity function
|
||||||
@@ -336,10 +515,9 @@ def test_entity_duplicate_validator() -> None:
|
|||||||
config1 = {CONF_NAME: "Temperature"}
|
config1 = {CONF_NAME: "Temperature"}
|
||||||
validated1 = validator(config1)
|
validated1 = validator(config1)
|
||||||
assert validated1 == config1
|
assert validated1 == config1
|
||||||
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
|
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||||
assert temperature_key in CORE.unique_ids
|
|
||||||
# Check metadata was stored
|
# Check metadata was stored
|
||||||
metadata = CORE.unique_ids[temperature_key]
|
metadata = CORE.unique_ids[("", "sensor", fnv1_hash("temperature"))]
|
||||||
assert metadata["name"] == "Temperature"
|
assert metadata["name"] == "Temperature"
|
||||||
assert metadata["platform"] == "sensor"
|
assert metadata["platform"] == "sensor"
|
||||||
|
|
||||||
@@ -347,9 +525,8 @@ def test_entity_duplicate_validator() -> None:
|
|||||||
config2 = {CONF_NAME: "Humidity"}
|
config2 = {CONF_NAME: "Humidity"}
|
||||||
validated2 = validator(config2)
|
validated2 = validator(config2)
|
||||||
assert validated2 == config2
|
assert validated2 == config2
|
||||||
humidity_key = ("", "sensor", fnv1_hash_name("Humidity"))
|
assert ("", "sensor", fnv1_hash("humidity")) in CORE.unique_ids
|
||||||
assert humidity_key in CORE.unique_ids
|
metadata2 = CORE.unique_ids[("", "sensor", fnv1_hash("humidity"))]
|
||||||
metadata2 = CORE.unique_ids[humidity_key]
|
|
||||||
assert metadata2["name"] == "Humidity"
|
assert metadata2["name"] == "Humidity"
|
||||||
|
|
||||||
# Duplicate entity should fail
|
# Duplicate entity should fail
|
||||||
@@ -360,6 +537,34 @@ def test_entity_duplicate_validator() -> None:
|
|||||||
validator(config3)
|
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:
|
def test_entity_duplicate_validator_with_devices() -> None:
|
||||||
"""Test entity_duplicate_validator with devices."""
|
"""Test entity_duplicate_validator with devices."""
|
||||||
# Create validator for sensor platform
|
# Create validator for sensor platform
|
||||||
@@ -370,19 +575,18 @@ def test_entity_duplicate_validator_with_devices() -> None:
|
|||||||
device2 = ID("device2", type="Device")
|
device2 = ID("device2", type="Device")
|
||||||
|
|
||||||
# Same name on different devices should pass
|
# Same name on different devices should pass
|
||||||
name_hash = fnv1_hash_name("Temperature")
|
|
||||||
config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
|
config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1}
|
||||||
validated1 = validator(config1)
|
validated1 = validator(config1)
|
||||||
assert validated1 == config1
|
assert validated1 == config1
|
||||||
assert ("device1", "sensor", name_hash) in CORE.unique_ids
|
assert ("device1", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||||
metadata1 = CORE.unique_ids[("device1", "sensor", name_hash)]
|
metadata1 = CORE.unique_ids[("device1", "sensor", fnv1_hash("temperature"))]
|
||||||
assert metadata1["device_id"] == "device1"
|
assert metadata1["device_id"] == "device1"
|
||||||
|
|
||||||
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
|
config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2}
|
||||||
validated2 = validator(config2)
|
validated2 = validator(config2)
|
||||||
assert validated2 == config2
|
assert validated2 == config2
|
||||||
assert ("device2", "sensor", name_hash) in CORE.unique_ids
|
assert ("device2", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||||
metadata2 = CORE.unique_ids[("device2", "sensor", name_hash)]
|
metadata2 = CORE.unique_ids[("device2", "sensor", fnv1_hash("temperature"))]
|
||||||
assert metadata2["device_id"] == "device2"
|
assert metadata2["device_id"] == "device2"
|
||||||
|
|
||||||
# Duplicate on same device should fail
|
# Duplicate on same device should fail
|
||||||
@@ -434,33 +638,6 @@ def test_entity_different_platforms_yaml_validation(
|
|||||||
assert result is not None
|
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:
|
def test_entity_duplicate_validator_error_message() -> None:
|
||||||
"""Test that duplicate entity error messages include helpful metadata."""
|
"""Test that duplicate entity error messages include helpful metadata."""
|
||||||
# Create validator for sensor platform
|
# Create validator for sensor platform
|
||||||
@@ -519,8 +696,7 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
|||||||
validated1 = validator(config1)
|
validated1 = validator(config1)
|
||||||
assert validated1 == config1
|
assert validated1 == config1
|
||||||
# New format includes device_id (empty string for main device)
|
# New format includes device_id (empty string for main device)
|
||||||
temperature_key = ("", "sensor", fnv1_hash_name("Temperature"))
|
assert ("", "sensor", fnv1_hash("temperature")) in CORE.unique_ids
|
||||||
assert temperature_key in CORE.unique_ids
|
|
||||||
|
|
||||||
# Internal entity with same name should pass (not added to unique_ids)
|
# Internal entity with same name should pass (not added to unique_ids)
|
||||||
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
|
config2 = {CONF_NAME: "Temperature", CONF_INTERNAL: True}
|
||||||
@@ -528,7 +704,9 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
|||||||
assert validated2 == config2
|
assert validated2 == config2
|
||||||
# Internal entity should not be added to unique_ids
|
# Internal entity should not be added to unique_ids
|
||||||
# Count how many times the key appears (should still be 1)
|
# Count how many times the key appears (should still be 1)
|
||||||
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
|
count = sum(
|
||||||
|
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
|
||||||
|
)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
# Another internal entity with same name should also pass
|
# Another internal entity with same name should also pass
|
||||||
@@ -536,7 +714,9 @@ def test_entity_duplicate_validator_internal_entities() -> None:
|
|||||||
validated3 = validator(config3)
|
validated3 = validator(config3)
|
||||||
assert validated3 == config3
|
assert validated3 == config3
|
||||||
# Still only one entry in unique_ids (from the non-internal entity)
|
# Still only one entry in unique_ids (from the non-internal entity)
|
||||||
count = sum(1 for k in CORE.unique_ids if k == temperature_key)
|
count = sum(
|
||||||
|
1 for k in CORE.unique_ids if k == ("", "sensor", fnv1_hash("temperature"))
|
||||||
|
)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
# Non-internal entity with same name should fail
|
# Non-internal entity with same name should fail
|
||||||
@@ -564,148 +744,30 @@ def test_empty_or_null_device_id_on_entity() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_entity_duplicate_validator_non_ascii_names() -> None:
|
def test_entity_duplicate_validator_non_ascii_names() -> None:
|
||||||
"""Test that distinct non-ASCII names no longer collide.
|
"""Test that non-ASCII names show helpful error messages."""
|
||||||
|
|
||||||
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
|
# Create validator for binary_sensor platform
|
||||||
validator = entity_duplicate_validator("binary_sensor")
|
validator = entity_duplicate_validator("binary_sensor")
|
||||||
|
|
||||||
# Both Russian sensors should pass even though they sanitize identically
|
# First Russian sensor should pass
|
||||||
config1 = {CONF_NAME: "Датчик открытия основного крана"}
|
config1 = {CONF_NAME: "Датчик открытия основного крана"}
|
||||||
validated1 = validator(config1)
|
validated1 = validator(config1)
|
||||||
assert validated1 == config1
|
assert validated1 == config1
|
||||||
|
|
||||||
|
# Second Russian sensor with different text but same ASCII conversion should fail
|
||||||
config2 = {CONF_NAME: "Датчик закрытия основного крана"}
|
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(
|
with pytest.raises(
|
||||||
Invalid,
|
Invalid,
|
||||||
match=re.compile(
|
match=re.compile(
|
||||||
rf"Duplicate sensor entity with name '{name_b}' found.*"
|
r"Duplicate binary_sensor entity with name 'Датчик закрытия основного крана' found.*"
|
||||||
rf"The names '{name_b}' and '{name_a}' produce the.*"
|
r"Original names: 'Датчик закрытия основного крана' and 'Датчик открытия основного крана'.*"
|
||||||
r"same entity key hash \(0x0ee5ff7b\).*"
|
r"Both convert to ASCII ID: '_______________________________'.*"
|
||||||
r"To fix: Rename one of the entities",
|
r"To fix: Add unique ASCII characters \(e\.g\., '1', '2', or 'A', 'B'\)",
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
validator(config2)
|
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:
|
def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None:
|
||||||
"""Test that identical names don't show the enhanced message."""
|
"""Test that identical names don't show the enhanced message."""
|
||||||
# Create validator for sensor platform
|
# Create validator for sensor platform
|
||||||
@@ -763,7 +825,7 @@ async def test_setup_entity_empty_name_with_device(
|
|||||||
|
|
||||||
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
||||||
assert config.get("_entity_name") == ""
|
assert config.get("_entity_name") == ""
|
||||||
assert config.get("_entity_key") == 0
|
assert config.get("_entity_object_id_hash") == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -792,7 +854,7 @@ async def test_setup_entity_empty_name_with_mac_suffix(
|
|||||||
|
|
||||||
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
||||||
assert config.get("_entity_name") == ""
|
assert config.get("_entity_name") == ""
|
||||||
assert config.get("_entity_key") == 0
|
assert config.get("_entity_object_id_hash") == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -822,7 +884,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
|
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
||||||
assert config.get("_entity_name") == ""
|
assert config.get("_entity_name") == ""
|
||||||
assert config.get("_entity_key") == 0
|
assert config.get("_entity_object_id_hash") == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -853,7 +915,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
|
# For empty-name entities, Python stores hash 0 - C++ calculates hash at runtime
|
||||||
assert config.get("_entity_name") == ""
|
assert config.get("_entity_name") == ""
|
||||||
assert config.get("_entity_key") == 0
|
assert config.get("_entity_object_id_hash") == 0
|
||||||
|
|
||||||
|
|
||||||
def test_register_string_overflow() -> None:
|
def test_register_string_overflow() -> None:
|
||||||
|
|||||||
@@ -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.
|
firmware upgrades, or break entity state routing to API clients.
|
||||||
|
|
||||||
Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85):
|
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).
|
1. `fnv1_hash_object_id(name)` - the object_id hash (snake_case + sanitize, then FNV-1).
|
||||||
Existing devices have preferences stored under keys derived from it; slot-based
|
The entity key sent to API clients and the base of every stored preference key.
|
||||||
backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it.
|
2. `fnv1_hash_name(name)` - FNV-1 over the raw UTF-8 name bytes. 2026.8 beta
|
||||||
2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes).
|
firmware stored preferences under keys derived from it; a future key migration
|
||||||
Sent to API clients and used as the preference key base on key-lookup backends.
|
must reconstruct those keys to recover that data.
|
||||||
|
|
||||||
DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm,
|
DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm,
|
||||||
the change breaks backward compatibility and will cause data loss.
|
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.
|
"""Verify fnv1_hash_object_id produces stable hashes for entity names.
|
||||||
|
|
||||||
CRITICAL: These expected values MUST NOT CHANGE. Existing devices have
|
CRITICAL: These expected values MUST NOT CHANGE. Existing devices have
|
||||||
preferences stored under keys derived from this legacy hash; changing it
|
preferences stored under keys derived from this hash, and it is the entity
|
||||||
breaks the old-to-new key migration and loses stored preferences.
|
key sent to API clients; changing it loses stored preferences and breaks
|
||||||
|
entity state routing.
|
||||||
"""
|
"""
|
||||||
actual = fnv1_hash_object_id(entity_name)
|
actual = fnv1_hash_object_id(entity_name)
|
||||||
assert actual == expected_object_id_hash, (
|
assert actual == expected_object_id_hash, (
|
||||||
@@ -144,9 +145,8 @@ def compute_legacy_preference_key(
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
|
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
|
||||||
|
|
||||||
This is the key existing devices have data stored under. Slot-based backends
|
This is the key EntityBase::make_entity_preference_() (entity_base.cpp)
|
||||||
(ESP8266, RP2040) still use it directly; key-lookup backends compute it as the
|
stores every entity preference under.
|
||||||
migration source in EntityBase::make_entity_preference_() (entity_base.cpp).
|
|
||||||
"""
|
"""
|
||||||
object_id_hash = fnv1_hash_object_id(entity_name)
|
object_id_hash = fnv1_hash_object_id(entity_name)
|
||||||
preference_hash = object_id_hash ^ device_id
|
preference_hash = object_id_hash ^ device_id
|
||||||
@@ -179,8 +179,8 @@ def test_legacy_preference_key_computation(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Verify legacy preference key computation matches expected values.
|
"""Verify legacy preference key computation matches expected values.
|
||||||
|
|
||||||
This test ensures the formula doesn't change, which would break both slot-based
|
This test ensures the formula doesn't change, which would lose stored
|
||||||
preference storage and the migration source keys on key-lookup backends.
|
preferences on every platform.
|
||||||
"""
|
"""
|
||||||
actual_key = compute_legacy_preference_key(entity_name, version, device_id)
|
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:
|
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
|
CRITICAL: These expected values MUST NOT CHANGE. 2026.8 beta firmware stored
|
||||||
API clients and is the new preference key base; changing the algorithm
|
preferences under keys derived from this hash; a future key migration must
|
||||||
would break state routing and lose stored preferences.
|
reconstruct those keys, and changing the algorithm would strand that data.
|
||||||
Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h.
|
Matched C++ fnv1_hash_bytes() (2026.8 beta), which the unrevert restores.
|
||||||
"""
|
"""
|
||||||
actual = fnv1_hash_name(entity_name)
|
actual = fnv1_hash_name(entity_name)
|
||||||
assert actual == expected_key, (
|
assert actual == expected_key, (
|
||||||
|
|||||||
Reference in New Issue
Block a user