mirror of
https://github.com/esphome/esphome.git
synced 2026-02-06 07:19:40 -07:00
Compare commits
41 Commits
dev
...
no_send_ob
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b99b1913 | ||
|
|
d39e1a98d4 | ||
|
|
c5be9027cb | ||
|
|
0c8077ca45 | ||
|
|
70038ea0a8 | ||
|
|
463a5b6af9 | ||
|
|
2756a027f7 | ||
|
|
64b61809a4 | ||
|
|
7a091c0ac6 | ||
|
|
c81aec9e58 | ||
|
|
da1955fefc | ||
|
|
8505a4dfaf | ||
|
|
071e42d4e7 | ||
|
|
38beb613c2 | ||
|
|
058c637b59 | ||
|
|
89ef523990 | ||
|
|
0ec741c425 | ||
|
|
c265436b07 | ||
|
|
04a75cf200 | ||
|
|
83598d6798 | ||
|
|
fa39b6bebd | ||
|
|
1beec0ecf1 | ||
|
|
3ef4e0bc47 | ||
|
|
bda2db9184 | ||
|
|
3009da14f1 | ||
|
|
d334d0d458 | ||
|
|
25b340cbbf | ||
|
|
fa2bc21d3d | ||
|
|
83d65cff5d | ||
|
|
9205cb3d67 | ||
|
|
f9a4a8a82e | ||
|
|
2d6b9b3888 | ||
|
|
da8e23f968 | ||
|
|
4bec2dc75c | ||
|
|
6d5ab00385 | ||
|
|
3e1db740ea | ||
|
|
e13f48b348 | ||
|
|
9f2d2eed8c | ||
|
|
b6b871cb73 | ||
|
|
452246e1c5 | ||
|
|
7944fe6993 |
@@ -1530,7 +1530,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) {
|
|||||||
|
|
||||||
HelloResponse resp;
|
HelloResponse resp;
|
||||||
resp.api_version_major = 1;
|
resp.api_version_major = 1;
|
||||||
resp.api_version_minor = 13;
|
resp.api_version_minor = 14;
|
||||||
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
||||||
resp.set_server_info(ESPHOME_VERSION_REF);
|
resp.set_server_info(ESPHOME_VERSION_REF);
|
||||||
resp.set_name(StringRef(App.get_name()));
|
resp.set_name(StringRef(App.get_name()));
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ struct ClientInfo {
|
|||||||
// Keepalive timeout in milliseconds
|
// Keepalive timeout in milliseconds
|
||||||
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
||||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||||
// This was increased from 20 to 24 after removing the unique_id field from entity info messages,
|
// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch
|
||||||
// which reduced message sizes allowing more entities per batch without exceeding packet limits
|
// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then
|
||||||
static constexpr size_t MAX_INITIAL_PER_BATCH = 24;
|
static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id)
|
||||||
|
static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id)
|
||||||
// Maximum number of packets to process in a single batch (platform-dependent)
|
// Maximum number of packets to process in a single batch (platform-dependent)
|
||||||
// This limit exists to prevent stack overflow from the PacketInfo array in process_batch_
|
// This limit exists to prevent stack overflow from the PacketInfo array in process_batch_
|
||||||
// Each PacketInfo is 8 bytes, so 64 * 8 = 512 bytes, 32 * 8 = 256 bytes
|
// Each PacketInfo is 8 bytes, so 64 * 8 = 512 bytes, 32 * 8 = 256 bytes
|
||||||
@@ -323,10 +324,16 @@ class APIConnection final : public APIServerConnection {
|
|||||||
APIConnection *conn, uint32_t remaining_size, bool is_single) {
|
APIConnection *conn, uint32_t remaining_size, bool is_single) {
|
||||||
// Set common fields that are shared by all entity types
|
// Set common fields that are shared by all entity types
|
||||||
msg.key = entity->get_object_id_hash();
|
msg.key = entity->get_object_id_hash();
|
||||||
// Get object_id with zero heap allocation
|
|
||||||
// Static case returns direct reference, dynamic case uses buffer
|
// API 1.14+ clients compute object_id client-side from the entity name
|
||||||
|
// For older clients, we must send object_id for backward compatibility
|
||||||
|
// See: https://github.com/esphome/backlog/issues/76
|
||||||
|
// TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then
|
||||||
|
// Buffer must remain in scope until encode_message_to_buffer is called
|
||||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
char object_id_buf[OBJECT_ID_MAX_LEN];
|
||||||
msg.set_object_id(entity->get_object_id_to(object_id_buf));
|
if (!conn->client_supports_api_version(1, 14)) {
|
||||||
|
msg.set_object_id(entity->get_object_id_to(object_id_buf));
|
||||||
|
}
|
||||||
|
|
||||||
if (entity->has_own_name()) {
|
if (entity->has_own_name()) {
|
||||||
msg.set_name(entity->get_name());
|
msg.set_name(entity->get_name());
|
||||||
@@ -349,16 +356,24 @@ class APIConnection final : public APIServerConnection {
|
|||||||
inline bool check_voice_assistant_api_connection_() const;
|
inline bool check_voice_assistant_api_connection_() const;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Get the max batch size based on client API version
|
||||||
|
// API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch
|
||||||
|
// TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly
|
||||||
|
size_t get_max_batch_size_() const {
|
||||||
|
return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper method to process multiple entities from an iterator in a batch
|
// Helper method to process multiple entities from an iterator in a batch
|
||||||
template<typename Iterator> void process_iterator_batch_(Iterator &iterator) {
|
template<typename Iterator> void process_iterator_batch_(Iterator &iterator) {
|
||||||
size_t initial_size = this->deferred_batch_.size();
|
size_t initial_size = this->deferred_batch_.size();
|
||||||
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < MAX_INITIAL_PER_BATCH) {
|
size_t max_batch = this->get_max_batch_size_();
|
||||||
|
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
|
||||||
iterator.advance();
|
iterator.advance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the batch is full, process it immediately
|
// If the batch is full, process it immediately
|
||||||
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
|
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
|
||||||
if (this->deferred_batch_.size() >= MAX_INITIAL_PER_BATCH) {
|
if (this->deferred_batch_.size() >= max_batch) {
|
||||||
this->process_batch_();
|
this->process_batch_();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ static const char *const TAG = "entity_base";
|
|||||||
|
|
||||||
// Entity Name
|
// Entity Name
|
||||||
const StringRef &EntityBase::get_name() const { return this->name_; }
|
const StringRef &EntityBase::get_name() const { return this->name_; }
|
||||||
void EntityBase::set_name(const char *name) {
|
void EntityBase::set_name(const char *name) { this->set_name(name, 0); }
|
||||||
|
void EntityBase::set_name(const char *name, uint32_t object_id_hash) {
|
||||||
this->name_ = StringRef(name);
|
this->name_ = StringRef(name);
|
||||||
if (this->name_.empty()) {
|
if (this->name_.empty()) {
|
||||||
#ifdef USE_DEVICES
|
#ifdef USE_DEVICES
|
||||||
@@ -18,11 +19,29 @@ void EntityBase::set_name(const char *name) {
|
|||||||
} else
|
} else
|
||||||
#endif
|
#endif
|
||||||
{
|
{
|
||||||
this->name_ = StringRef(App.get_friendly_name());
|
// Bug-for-bug compatibility with OLD behavior:
|
||||||
|
// - With MAC suffix: OLD code used App.get_friendly_name() directly (no fallback)
|
||||||
|
// - Without MAC suffix: OLD code used pre-computed object_id with fallback to device name
|
||||||
|
const std::string &friendly = App.get_friendly_name();
|
||||||
|
if (App.is_name_add_mac_suffix_enabled()) {
|
||||||
|
// MAC suffix enabled - use friendly_name directly (even if empty) for compatibility
|
||||||
|
this->name_ = StringRef(friendly);
|
||||||
|
} else {
|
||||||
|
// No MAC suffix - fallback to device name if friendly_name is empty
|
||||||
|
this->name_ = StringRef(!friendly.empty() ? friendly : App.get_name());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this->flags_.has_own_name = false;
|
this->flags_.has_own_name = false;
|
||||||
|
// Dynamic name - must calculate hash at runtime
|
||||||
|
this->calc_object_id_();
|
||||||
} else {
|
} else {
|
||||||
this->flags_.has_own_name = true;
|
this->flags_.has_own_name = true;
|
||||||
|
// Static name - use pre-computed hash if provided
|
||||||
|
if (object_id_hash != 0) {
|
||||||
|
this->object_id_hash_ = object_id_hash;
|
||||||
|
} else {
|
||||||
|
this->calc_object_id_();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,69 +64,30 @@ void EntityBase::set_icon(const char *icon) {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the object_id is dynamic (changes with MAC suffix)
|
// Entity Object ID - computed on-demand from name
|
||||||
bool EntityBase::is_object_id_dynamic_() const {
|
|
||||||
return !this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entity Object ID
|
|
||||||
std::string EntityBase::get_object_id() const {
|
std::string EntityBase::get_object_id() const {
|
||||||
// Check if `App.get_friendly_name()` is constant or dynamic.
|
|
||||||
if (this->is_object_id_dynamic_()) {
|
|
||||||
// `App.get_friendly_name()` is dynamic.
|
|
||||||
return str_sanitize(str_snake_case(App.get_friendly_name()));
|
|
||||||
}
|
|
||||||
// `App.get_friendly_name()` is constant.
|
|
||||||
return this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_;
|
|
||||||
}
|
|
||||||
void EntityBase::set_object_id(const char *object_id) {
|
|
||||||
this->object_id_c_str_ = object_id;
|
|
||||||
this->calc_object_id_();
|
|
||||||
}
|
|
||||||
|
|
||||||
void EntityBase::set_name_and_object_id(const char *name, const char *object_id) {
|
|
||||||
this->set_name(name);
|
|
||||||
this->object_id_c_str_ = object_id;
|
|
||||||
this->calc_object_id_();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate Object ID Hash from Entity Name
|
|
||||||
void EntityBase::calc_object_id_() {
|
|
||||||
char buf[OBJECT_ID_MAX_LEN];
|
char buf[OBJECT_ID_MAX_LEN];
|
||||||
StringRef object_id = this->get_object_id_to(buf);
|
size_t len = this->write_object_id_to(buf, sizeof(buf));
|
||||||
this->object_id_hash_ = fnv1_hash(object_id.c_str());
|
return std::string(buf, len);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format dynamic object_id: sanitized snake_case of friendly_name
|
// Calculate Object ID Hash directly from name using snake_case + sanitize
|
||||||
static size_t format_dynamic_object_id(char *buf, size_t buf_size) {
|
void EntityBase::calc_object_id_() {
|
||||||
const std::string &name = App.get_friendly_name();
|
this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size());
|
||||||
size_t len = std::min(name.size(), buf_size - 1);
|
|
||||||
for (size_t i = 0; i < len; i++) {
|
|
||||||
buf[i] = to_sanitized_char(to_snake_case_char(name[i]));
|
|
||||||
}
|
|
||||||
buf[len] = '\0';
|
|
||||||
return len;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
if (this->is_object_id_dynamic_()) {
|
size_t len = std::min(this->name_.size(), buf_size - 1);
|
||||||
return format_dynamic_object_id(buf, buf_size);
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
buf[i] = to_sanitized_char(to_snake_case_char(this->name_[i]));
|
||||||
}
|
}
|
||||||
const char *src = this->object_id_c_str_ == nullptr ? "" : this->object_id_c_str_;
|
|
||||||
size_t len = strlen(src);
|
|
||||||
if (len >= buf_size)
|
|
||||||
len = buf_size - 1;
|
|
||||||
memcpy(buf, src, len);
|
|
||||||
buf[len] = '\0';
|
buf[len] = '\0';
|
||||||
return len;
|
return len;
|
||||||
}
|
}
|
||||||
|
|
||||||
StringRef EntityBase::get_object_id_to(std::span<char, OBJECT_ID_MAX_LEN> buf) const {
|
StringRef EntityBase::get_object_id_to(std::span<char, OBJECT_ID_MAX_LEN> buf) const {
|
||||||
if (this->is_object_id_dynamic_()) {
|
size_t len = this->write_object_id_to(buf.data(), buf.size());
|
||||||
size_t len = format_dynamic_object_id(buf.data(), buf.size());
|
return StringRef(buf.data(), len);
|
||||||
return StringRef(buf.data(), len);
|
|
||||||
}
|
|
||||||
return this->object_id_c_str_ == nullptr ? StringRef() : StringRef(this->object_id_c_str_);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; }
|
uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; }
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class EntityBase {
|
|||||||
// Get/set the name of this Entity
|
// Get/set the name of this Entity
|
||||||
const StringRef &get_name() const;
|
const StringRef &get_name() const;
|
||||||
void set_name(const char *name);
|
void set_name(const char *name);
|
||||||
|
/// Set name with pre-computed object_id hash (avoids runtime hash calculation)
|
||||||
|
/// Use hash=0 for dynamic names that need runtime calculation
|
||||||
|
void set_name(const char *name, uint32_t object_id_hash);
|
||||||
|
|
||||||
// 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; }
|
||||||
@@ -43,10 +46,6 @@ class EntityBase {
|
|||||||
"which will remain available longer. get_object_id() will be removed in 2026.7.0",
|
"which will remain available longer. get_object_id() will be removed in 2026.7.0",
|
||||||
"2025.12.0")
|
"2025.12.0")
|
||||||
std::string get_object_id() const;
|
std::string get_object_id() const;
|
||||||
void set_object_id(const char *object_id);
|
|
||||||
|
|
||||||
// Set both name and object_id in one call (reduces generated code size)
|
|
||||||
void set_name_and_object_id(const char *name, const char *object_id);
|
|
||||||
|
|
||||||
// Get the unique Object ID of this Entity
|
// Get the unique Object ID of this Entity
|
||||||
uint32_t get_object_id_hash();
|
uint32_t get_object_id_hash();
|
||||||
@@ -142,11 +141,7 @@ class EntityBase {
|
|||||||
protected:
|
protected:
|
||||||
void calc_object_id_();
|
void calc_object_id_();
|
||||||
|
|
||||||
/// Check if the object_id is dynamic (changes with MAC suffix)
|
|
||||||
bool is_object_id_dynamic_() const;
|
|
||||||
|
|
||||||
StringRef name_;
|
StringRef name_;
|
||||||
const char *object_id_c_str_{nullptr};
|
|
||||||
#ifdef USE_ENTITY_ICON
|
#ifdef USE_ENTITY_ICON
|
||||||
const char *icon_c_str_{nullptr};
|
const char *icon_c_str_{nullptr};
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from esphome.const import (
|
|||||||
from esphome.core import CORE, ID
|
from esphome.core import CORE, ID
|
||||||
from esphome.cpp_generator import MockObj, add, get_variable
|
from esphome.cpp_generator import MockObj, add, get_variable
|
||||||
import esphome.final_validate as fv
|
import esphome.final_validate as fv
|
||||||
from esphome.helpers import sanitize, snake_case
|
from esphome.helpers import 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__)
|
||||||
@@ -75,34 +75,18 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None:
|
|||||||
config: Configuration dictionary containing entity settings
|
config: Configuration dictionary containing entity settings
|
||||||
platform: The platform name (e.g., "sensor", "binary_sensor")
|
platform: The platform name (e.g., "sensor", "binary_sensor")
|
||||||
"""
|
"""
|
||||||
# Get device info
|
# Get device info if configured
|
||||||
device_name: str | None = None
|
|
||||||
device_id_obj: ID | None
|
|
||||||
if device_id_obj := config.get(CONF_DEVICE_ID):
|
if device_id_obj := config.get(CONF_DEVICE_ID):
|
||||||
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))
|
||||||
# Get device name for object ID calculation
|
|
||||||
device_name = device_id_obj.id
|
|
||||||
|
|
||||||
# Calculate base object_id using the same logic as C++
|
# Set the entity name with pre-computed object_id hash
|
||||||
# This must match the C++ behavior in esphome/core/entity_base.cpp
|
# For named entities: pre-compute hash from entity name
|
||||||
base_object_id = get_base_entity_object_id(
|
# For empty-name entities: pass 0, C++ calculates hash at runtime from
|
||||||
config[CONF_NAME], CORE.friendly_name, device_name
|
# device name, friendly_name, or app name (bug-for-bug compatibility)
|
||||||
)
|
entity_name = config[CONF_NAME]
|
||||||
|
object_id_hash = fnv1_hash_object_id(entity_name) if entity_name else 0
|
||||||
if not config[CONF_NAME]:
|
add(var.set_name(entity_name, object_id_hash))
|
||||||
_LOGGER.debug(
|
|
||||||
"Entity has empty name, using '%s' as object_id base", base_object_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set both name and object_id in one call to reduce generated code size
|
|
||||||
add(var.set_name_and_object_id(config[CONF_NAME], base_object_id))
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Setting object_id '%s' for entity '%s' on platform '%s'",
|
|
||||||
base_object_id,
|
|
||||||
config[CONF_NAME],
|
|
||||||
platform,
|
|
||||||
)
|
|
||||||
# Only set disabled_by_default if True (default is False)
|
# Only set disabled_by_default if True (default is False)
|
||||||
if config[CONF_DISABLED_BY_DEFAULT]:
|
if config[CONF_DISABLED_BY_DEFAULT]:
|
||||||
add(var.set_disabled_by_default(True))
|
add(var.set_disabled_by_default(True))
|
||||||
|
|||||||
@@ -529,6 +529,20 @@ constexpr char to_sanitized_char(char c) {
|
|||||||
/// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores.
|
/// Sanitizes the input string by removing all characters but alphanumerics, dashes and underscores.
|
||||||
std::string str_sanitize(const std::string &str);
|
std::string str_sanitize(const std::string &str);
|
||||||
|
|
||||||
|
/// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations.
|
||||||
|
/// This computes object_id hashes directly from names without creating an intermediate buffer.
|
||||||
|
/// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py.
|
||||||
|
/// If you modify this function, update the Python version and tests in both places.
|
||||||
|
inline uint32_t fnv1_hash_object_id(const char *str, size_t len) {
|
||||||
|
uint32_t hash = FNV1_OFFSET_BASIS;
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
hash *= FNV1_PRIME;
|
||||||
|
// Apply snake_case (space->underscore, uppercase->lowercase) then sanitize
|
||||||
|
hash ^= static_cast<uint8_t>(to_sanitized_char(to_snake_case_char(str[i])));
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
/// snprintf-like function returning std::string of maximum length \p len (excluding null terminator).
|
/// snprintf-like function returning std::string of maximum length \p len (excluding null terminator).
|
||||||
std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...);
|
std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, size_t len, ...);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ IS_MACOS = platform.system() == "Darwin"
|
|||||||
IS_WINDOWS = platform.system() == "Windows"
|
IS_WINDOWS = platform.system() == "Windows"
|
||||||
IS_LINUX = platform.system() == "Linux"
|
IS_LINUX = platform.system() == "Linux"
|
||||||
|
|
||||||
|
# FNV-1 hash constants (must match C++ in esphome/core/helpers.h)
|
||||||
|
FNV1_OFFSET_BASIS = 2166136261
|
||||||
|
FNV1_PRIME = 16777619
|
||||||
|
|
||||||
|
|
||||||
def ensure_unique_string(preferred_string, current_strings):
|
def ensure_unique_string(preferred_string, current_strings):
|
||||||
test_string = preferred_string
|
test_string = preferred_string
|
||||||
@@ -49,8 +53,17 @@ def ensure_unique_string(preferred_string, current_strings):
|
|||||||
return test_string
|
return test_string
|
||||||
|
|
||||||
|
|
||||||
|
def fnv1_hash(string: str) -> int:
|
||||||
|
"""FNV-1 32-bit hash function (multiply then XOR)."""
|
||||||
|
hash_value = FNV1_OFFSET_BASIS
|
||||||
|
for char in string:
|
||||||
|
hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF
|
||||||
|
hash_value ^= ord(char)
|
||||||
|
return hash_value
|
||||||
|
|
||||||
|
|
||||||
def fnv1a_32bit_hash(string: str) -> int:
|
def fnv1a_32bit_hash(string: str) -> int:
|
||||||
"""FNV-1a 32-bit hash function.
|
"""FNV-1a 32-bit hash function (XOR then multiply).
|
||||||
|
|
||||||
Note: This uses 32-bit hash instead of 64-bit for several reasons:
|
Note: This uses 32-bit hash instead of 64-bit for several reasons:
|
||||||
1. ESPHome targets 32-bit microcontrollers with limited RAM (often <320KB)
|
1. ESPHome targets 32-bit microcontrollers with limited RAM (often <320KB)
|
||||||
@@ -63,13 +76,22 @@ def fnv1a_32bit_hash(string: str) -> int:
|
|||||||
a handful of area_ids and device_ids (typically <10 areas and <100
|
a handful of area_ids and device_ids (typically <10 areas and <100
|
||||||
devices), making collisions virtually impossible.
|
devices), making collisions virtually impossible.
|
||||||
"""
|
"""
|
||||||
hash_value = 2166136261
|
hash_value = FNV1_OFFSET_BASIS
|
||||||
for char in string:
|
for char in string:
|
||||||
hash_value ^= ord(char)
|
hash_value ^= ord(char)
|
||||||
hash_value = (hash_value * 16777619) & 0xFFFFFFFF
|
hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF
|
||||||
return hash_value
|
return hash_value
|
||||||
|
|
||||||
|
|
||||||
|
def fnv1_hash_object_id(name: str) -> int:
|
||||||
|
"""Compute FNV-1 hash of name with snake_case + sanitize transformations.
|
||||||
|
|
||||||
|
IMPORTANT: Must produce same result as C++ fnv1_hash_object_id() in helpers.h.
|
||||||
|
Used for pre-computing entity object_id hashes at code generation time.
|
||||||
|
"""
|
||||||
|
return fnv1_hash(sanitize(snake_case(name)))
|
||||||
|
|
||||||
|
|
||||||
def strip_accents(value: str) -> str:
|
def strip_accents(value: str) -> str:
|
||||||
"""Remove accents from a string."""
|
"""Remove accents from a string."""
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Then
|
# Then
|
||||||
assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp
|
assert 'bs_1->set_name("test bs1",' in main_cpp
|
||||||
assert "bs_1->set_pin(" in main_cpp
|
assert "bs_1->set_pin(" in main_cpp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main):
|
|||||||
main_cpp = generate_main("tests/component_tests/button/test_button.yaml")
|
main_cpp = generate_main("tests/component_tests/button/test_button.yaml")
|
||||||
|
|
||||||
# Then
|
# Then
|
||||||
assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp
|
assert 'wol_1->set_name("wol_test_1",' in main_cpp
|
||||||
assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp
|
assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def test_text_sets_mandatory_fields(generate_main):
|
|||||||
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
|
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
|
||||||
|
|
||||||
# Then
|
# Then
|
||||||
assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp
|
assert 'it_1->set_name("test 1 text",' in main_cpp
|
||||||
|
|
||||||
|
|
||||||
def test_text_config_value_internal_set(generate_main):
|
def test_text_config_value_internal_set(generate_main):
|
||||||
|
|||||||
@@ -25,18 +25,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main):
|
|||||||
main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml")
|
main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml")
|
||||||
|
|
||||||
# Then
|
# Then
|
||||||
assert (
|
assert 'ts_1->set_name("Template Text Sensor 1",' in main_cpp
|
||||||
'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");'
|
assert 'ts_2->set_name("Template Text Sensor 2",' in main_cpp
|
||||||
in main_cpp
|
assert 'ts_3->set_name("Template Text Sensor 3",' in main_cpp
|
||||||
)
|
|
||||||
assert (
|
|
||||||
'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");'
|
|
||||||
in main_cpp
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");'
|
|
||||||
in main_cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_text_sensor_config_value_internal_set(generate_main):
|
def test_text_sensor_config_value_internal_set(generate_main):
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ if platform.system() == "Windows":
|
|||||||
|
|
||||||
import pty # not available on Windows
|
import pty # not available on Windows
|
||||||
|
|
||||||
|
# Register assert rewrite for entity_utils so assertions have proper error messages
|
||||||
|
pytest.register_assert_rewrite("tests.integration.entity_utils")
|
||||||
|
|
||||||
|
|
||||||
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||||
"""Get environment variables for PlatformIO with shared cache."""
|
"""Get environment variables for PlatformIO with shared cache."""
|
||||||
|
|||||||
145
tests/integration/entity_utils.py
Normal file
145
tests/integration/entity_utils.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""Utilities for computing entity object_id in integration tests.
|
||||||
|
|
||||||
|
This module contains the algorithm that aioesphomeapi will use to compute
|
||||||
|
object_id client-side from API data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from aioesphomeapi import DeviceInfo, EntityInfo
|
||||||
|
|
||||||
|
|
||||||
|
def compute_object_id(name: str) -> str:
|
||||||
|
"""Compute object_id from name using snake_case + sanitize."""
|
||||||
|
return sanitize(snake_case(name))
|
||||||
|
|
||||||
|
|
||||||
|
def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool:
|
||||||
|
"""Infer name_add_mac_suffix from device name ending with MAC suffix."""
|
||||||
|
mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower()
|
||||||
|
return device_info.name.endswith(f"-{mac_suffix}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_name_for_object_id(
|
||||||
|
entity: EntityInfo,
|
||||||
|
device_info: DeviceInfo,
|
||||||
|
device_id_to_name: dict[int, str],
|
||||||
|
) -> str:
|
||||||
|
"""Get the name used for object_id computation.
|
||||||
|
|
||||||
|
This is the algorithm that aioesphomeapi will use to determine which
|
||||||
|
name to use for computing object_id client-side from API data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity: The entity to get name for
|
||||||
|
device_info: Device info from the API
|
||||||
|
device_id_to_name: Mapping of device_id to device name for sub-devices
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The name to use for object_id computation
|
||||||
|
"""
|
||||||
|
if entity.name:
|
||||||
|
# Named entity: use entity name
|
||||||
|
return entity.name
|
||||||
|
if entity.device_id != 0:
|
||||||
|
# Empty name on sub-device: use sub-device name
|
||||||
|
return device_id_to_name[entity.device_id]
|
||||||
|
if infer_name_add_mac_suffix(device_info) or device_info.friendly_name:
|
||||||
|
# Empty name on main device with MAC suffix or friendly_name: use friendly_name
|
||||||
|
# (even if empty - this is bug-for-bug compatibility for MAC suffix case)
|
||||||
|
return device_info.friendly_name
|
||||||
|
# Empty name on main device, no friendly_name: use device name
|
||||||
|
return device_info.name
|
||||||
|
|
||||||
|
|
||||||
|
def compute_entity_object_id(
|
||||||
|
entity: EntityInfo,
|
||||||
|
device_info: DeviceInfo,
|
||||||
|
device_id_to_name: dict[int, str],
|
||||||
|
) -> str:
|
||||||
|
"""Compute expected object_id for an entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity: The entity to compute object_id for
|
||||||
|
device_info: Device info from the API
|
||||||
|
device_id_to_name: Mapping of device_id to device name for sub-devices
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The computed object_id string
|
||||||
|
"""
|
||||||
|
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
|
||||||
|
return compute_object_id(name_for_id)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_entity_hash(
|
||||||
|
entity: EntityInfo,
|
||||||
|
device_info: DeviceInfo,
|
||||||
|
device_id_to_name: dict[int, str],
|
||||||
|
) -> int:
|
||||||
|
"""Compute expected object_id hash for an entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity: The entity to compute hash for
|
||||||
|
device_info: Device info from the API
|
||||||
|
device_id_to_name: Mapping of device_id to device name for sub-devices
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The computed FNV-1 hash
|
||||||
|
"""
|
||||||
|
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
|
||||||
|
return fnv1_hash_object_id(name_for_id)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_entity_object_id(
|
||||||
|
entity: EntityInfo,
|
||||||
|
device_info: DeviceInfo,
|
||||||
|
device_id_to_name: dict[int, str],
|
||||||
|
) -> None:
|
||||||
|
"""Verify an entity's object_id and hash match the expected values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entity: The entity to verify
|
||||||
|
device_info: Device info from the API
|
||||||
|
device_id_to_name: Mapping of device_id to device name for sub-devices
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AssertionError: If object_id or hash doesn't match expected value
|
||||||
|
"""
|
||||||
|
expected_object_id = compute_entity_object_id(
|
||||||
|
entity, device_info, device_id_to_name
|
||||||
|
)
|
||||||
|
assert entity.object_id == expected_object_id, (
|
||||||
|
f"object_id mismatch for entity '{entity.name}': "
|
||||||
|
f"expected '{expected_object_id}', got '{entity.object_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_hash = compute_entity_hash(entity, device_info, device_id_to_name)
|
||||||
|
assert entity.key == expected_hash, (
|
||||||
|
f"hash mismatch for entity '{entity.name}': "
|
||||||
|
f"expected {expected_hash:#x}, got {entity.key:#x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_all_entities(
|
||||||
|
entities: list[EntityInfo],
|
||||||
|
device_info: DeviceInfo,
|
||||||
|
) -> None:
|
||||||
|
"""Verify all entities have correct object_id and hash values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entities: List of entities to verify
|
||||||
|
device_info: Device info from the API
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AssertionError: If any entity's object_id or hash doesn't match
|
||||||
|
"""
|
||||||
|
# Build device_id -> name lookup from sub-devices
|
||||||
|
device_id_to_name = {d.device_id: d.name for d in device_info.devices}
|
||||||
|
|
||||||
|
for entity in entities:
|
||||||
|
verify_entity_object_id(entity, device_info, device_id_to_name)
|
||||||
76
tests/integration/fixtures/fnv1_hash_object_id.yaml
Normal file
76
tests/integration/fixtures/fnv1_hash_object_id.yaml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
esphome:
|
||||||
|
name: fnv1-hash-object-id-test
|
||||||
|
platformio_options:
|
||||||
|
build_flags:
|
||||||
|
- "-DDEBUG"
|
||||||
|
on_boot:
|
||||||
|
- lambda: |-
|
||||||
|
using esphome::fnv1_hash_object_id;
|
||||||
|
|
||||||
|
// Test basic lowercase (hash matches Python fnv1_hash_object_id("foo"))
|
||||||
|
uint32_t hash_foo = fnv1_hash_object_id("foo", 3);
|
||||||
|
if (hash_foo == 0x408f5e13) {
|
||||||
|
ESP_LOGI("FNV1_OID", "foo PASSED");
|
||||||
|
} else {
|
||||||
|
ESP_LOGE("FNV1_OID", "foo FAILED: 0x%08x != 0x408f5e13", hash_foo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test uppercase conversion (should match lowercase)
|
||||||
|
uint32_t hash_Foo = fnv1_hash_object_id("Foo", 3);
|
||||||
|
if (hash_Foo == 0x408f5e13) {
|
||||||
|
ESP_LOGI("FNV1_OID", "upper PASSED");
|
||||||
|
} else {
|
||||||
|
ESP_LOGE("FNV1_OID", "upper FAILED: 0x%08x != 0x408f5e13", hash_Foo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test space to underscore conversion ("foo bar" -> "foo_bar")
|
||||||
|
uint32_t hash_space = fnv1_hash_object_id("foo bar", 7);
|
||||||
|
if (hash_space == 0x3ae35aa1) {
|
||||||
|
ESP_LOGI("FNV1_OID", "space PASSED");
|
||||||
|
} else {
|
||||||
|
ESP_LOGE("FNV1_OID", "space FAILED: 0x%08x != 0x3ae35aa1", hash_space);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test underscore preserved ("foo_bar")
|
||||||
|
uint32_t hash_underscore = fnv1_hash_object_id("foo_bar", 7);
|
||||||
|
if (hash_underscore == 0x3ae35aa1) {
|
||||||
|
ESP_LOGI("FNV1_OID", "underscore PASSED");
|
||||||
|
} else {
|
||||||
|
ESP_LOGE("FNV1_OID", "underscore FAILED: 0x%08x != 0x3ae35aa1", hash_underscore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test hyphen preserved ("foo-bar")
|
||||||
|
uint32_t hash_hyphen = fnv1_hash_object_id("foo-bar", 7);
|
||||||
|
if (hash_hyphen == 0x438b12e3) {
|
||||||
|
ESP_LOGI("FNV1_OID", "hyphen PASSED");
|
||||||
|
} else {
|
||||||
|
ESP_LOGE("FNV1_OID", "hyphen FAILED: 0x%08x != 0x438b12e3", hash_hyphen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test special chars become underscore ("foo!bar" -> "foo_bar")
|
||||||
|
uint32_t hash_special = fnv1_hash_object_id("foo!bar", 7);
|
||||||
|
if (hash_special == 0x3ae35aa1) {
|
||||||
|
ESP_LOGI("FNV1_OID", "special PASSED");
|
||||||
|
} else {
|
||||||
|
ESP_LOGE("FNV1_OID", "special FAILED: 0x%08x != 0x3ae35aa1", hash_special);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test complex name ("My Sensor Name" -> "my_sensor_name")
|
||||||
|
uint32_t hash_complex = fnv1_hash_object_id("My Sensor Name", 14);
|
||||||
|
if (hash_complex == 0x2760962a) {
|
||||||
|
ESP_LOGI("FNV1_OID", "complex PASSED");
|
||||||
|
} else {
|
||||||
|
ESP_LOGE("FNV1_OID", "complex FAILED: 0x%08x != 0x2760962a", hash_complex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test empty string returns FNV1_OFFSET_BASIS
|
||||||
|
uint32_t hash_empty = fnv1_hash_object_id("", 0);
|
||||||
|
if (hash_empty == 0x811c9dc5) {
|
||||||
|
ESP_LOGI("FNV1_OID", "empty PASSED");
|
||||||
|
} else {
|
||||||
|
ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
host:
|
||||||
|
api:
|
||||||
|
logger:
|
||||||
125
tests/integration/fixtures/object_id_api_verification.yaml
Normal file
125
tests/integration/fixtures/object_id_api_verification.yaml
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
esphome:
|
||||||
|
name: object-id-test
|
||||||
|
friendly_name: Test Device
|
||||||
|
# Enable MAC suffix - host MAC is 98:35:69:ab:f6:79, suffix is "abf679"
|
||||||
|
# friendly_name becomes "Test Device abf679"
|
||||||
|
name_add_mac_suffix: true
|
||||||
|
# Sub-devices for testing empty-name entities on devices
|
||||||
|
devices:
|
||||||
|
- id: sub_device_1
|
||||||
|
name: Sub Device One
|
||||||
|
- id: sub_device_2
|
||||||
|
name: Sub Device Two
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
|
||||||
|
logger:
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
# Test 1: Basic name -> object_id = "temperature_sensor"
|
||||||
|
- platform: template
|
||||||
|
name: "Temperature Sensor"
|
||||||
|
id: sensor_basic
|
||||||
|
lambda: return 42.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Test 2: Uppercase name -> object_id = "uppercase_name"
|
||||||
|
- platform: template
|
||||||
|
name: "UPPERCASE NAME"
|
||||||
|
id: sensor_uppercase
|
||||||
|
lambda: return 43.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Test 3: Special characters -> object_id = "special__chars_"
|
||||||
|
- platform: template
|
||||||
|
name: "Special!@Chars#"
|
||||||
|
id: sensor_special
|
||||||
|
lambda: return 44.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Test 4: Hyphen preserved -> object_id = "temp-sensor"
|
||||||
|
- platform: template
|
||||||
|
name: "Temp-Sensor"
|
||||||
|
id: sensor_hyphen
|
||||||
|
lambda: return 45.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Test 5: Underscore preserved -> object_id = "temp_sensor"
|
||||||
|
- platform: template
|
||||||
|
name: "Temp_Sensor"
|
||||||
|
id: sensor_underscore
|
||||||
|
lambda: return 46.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Test 6: Mixed case with spaces -> object_id = "living_room_temperature"
|
||||||
|
- platform: template
|
||||||
|
name: "Living Room Temperature"
|
||||||
|
id: sensor_mixed
|
||||||
|
lambda: return 47.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Test 7: Empty name - uses friendly_name with MAC suffix
|
||||||
|
# friendly_name = "Test Device abf679" -> object_id = "test_device_abf679"
|
||||||
|
- platform: template
|
||||||
|
name: ""
|
||||||
|
id: sensor_empty_name
|
||||||
|
lambda: return 48.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
binary_sensor:
|
||||||
|
# Test 8: Different platform same conversion rules
|
||||||
|
- platform: template
|
||||||
|
name: "Door Open"
|
||||||
|
id: binary_door
|
||||||
|
lambda: return true;
|
||||||
|
|
||||||
|
# Test 9: Numbers in name -> object_id = "sensor_123"
|
||||||
|
- platform: template
|
||||||
|
name: "Sensor 123"
|
||||||
|
id: binary_numbers
|
||||||
|
lambda: return false;
|
||||||
|
|
||||||
|
switch:
|
||||||
|
# Test 10: Long name with multiple spaces
|
||||||
|
- platform: template
|
||||||
|
name: "My Very Long Switch Name Here"
|
||||||
|
id: switch_long
|
||||||
|
lambda: return false;
|
||||||
|
turn_on_action:
|
||||||
|
- logger.log: "on"
|
||||||
|
turn_off_action:
|
||||||
|
- logger.log: "off"
|
||||||
|
|
||||||
|
text_sensor:
|
||||||
|
# Test 11: Name starting with number (should work fine)
|
||||||
|
- platform: template
|
||||||
|
name: "123 Start"
|
||||||
|
id: text_num_start
|
||||||
|
lambda: return {"test"};
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
button:
|
||||||
|
# Test 12: Named entity on sub-device -> object_id from entity name
|
||||||
|
- platform: template
|
||||||
|
name: "Device Button"
|
||||||
|
id: button_on_device
|
||||||
|
device_id: sub_device_1
|
||||||
|
on_press: []
|
||||||
|
|
||||||
|
# Test 13: Empty name on sub-device -> object_id from device name
|
||||||
|
# Device name "Sub Device One" -> object_id = "sub_device_one"
|
||||||
|
- platform: template
|
||||||
|
name: ""
|
||||||
|
id: button_empty_on_device1
|
||||||
|
device_id: sub_device_1
|
||||||
|
on_press: []
|
||||||
|
|
||||||
|
# Test 14: Empty name on different sub-device
|
||||||
|
# Device name "Sub Device Two" -> object_id = "sub_device_two"
|
||||||
|
- platform: template
|
||||||
|
name: ""
|
||||||
|
id: button_empty_on_device2
|
||||||
|
device_id: sub_device_2
|
||||||
|
on_press: []
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
esphome:
|
||||||
|
name: test-device
|
||||||
|
# friendly_name set but NO MAC suffix
|
||||||
|
# Empty-name entity should use friendly_name for object_id
|
||||||
|
friendly_name: My Friendly Device
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
|
||||||
|
logger:
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
# Empty name entity - should use friendly_name for object_id
|
||||||
|
# friendly_name = "My Friendly Device" -> object_id = "my_friendly_device"
|
||||||
|
- platform: template
|
||||||
|
name: ""
|
||||||
|
id: sensor_empty_name
|
||||||
|
lambda: return 42.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Named entity for comparison
|
||||||
|
- platform: template
|
||||||
|
name: "Temperature"
|
||||||
|
id: sensor_named
|
||||||
|
lambda: return 43.0;
|
||||||
|
update_interval: 60s
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
esphome:
|
||||||
|
name: test-device
|
||||||
|
# No friendly_name set, no MAC suffix
|
||||||
|
# OLD behavior: object_id = device name because Python pre-computed with fallback
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
|
||||||
|
logger:
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
# Empty name entity - OLD behavior used device name as fallback
|
||||||
|
- platform: template
|
||||||
|
name: ""
|
||||||
|
id: sensor_empty_name
|
||||||
|
lambda: return 42.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Named entity for comparison
|
||||||
|
- platform: template
|
||||||
|
name: "Temperature"
|
||||||
|
id: sensor_named
|
||||||
|
lambda: return 43.0;
|
||||||
|
update_interval: 60s
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
esphome:
|
||||||
|
name: test-device
|
||||||
|
# No friendly_name set, MAC suffix enabled
|
||||||
|
# OLD behavior: object_id = "" (empty) because is_object_id_dynamic_() used App.get_friendly_name() directly
|
||||||
|
name_add_mac_suffix: true
|
||||||
|
|
||||||
|
host:
|
||||||
|
|
||||||
|
api:
|
||||||
|
|
||||||
|
logger:
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
# Empty name entity - OLD behavior produced empty object_id when MAC suffix enabled
|
||||||
|
- platform: template
|
||||||
|
name: ""
|
||||||
|
id: sensor_empty_name
|
||||||
|
lambda: return 42.0;
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
# Named entity for comparison
|
||||||
|
- platform: template
|
||||||
|
name: "Temperature"
|
||||||
|
id: sensor_named
|
||||||
|
lambda: return 43.0;
|
||||||
|
update_interval: 60s
|
||||||
75
tests/integration/test_fnv1_hash_object_id.py
Normal file
75
tests/integration/test_fnv1_hash_object_id.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Integration test for fnv1_hash_object_id function.
|
||||||
|
|
||||||
|
This test verifies that the C++ fnv1_hash_object_id() function in
|
||||||
|
esphome/core/helpers.h produces the same hash values as the Python
|
||||||
|
fnv1_hash_object_id() function in esphome/helpers.py.
|
||||||
|
|
||||||
|
If this test fails, one of the implementations has diverged and needs
|
||||||
|
to be updated to match the other.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fnv1_hash_object_id(
|
||||||
|
yaml_config: str,
|
||||||
|
run_compiled: RunCompiledFunction,
|
||||||
|
api_client_connected: APIClientConnectedFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test that C++ fnv1_hash_object_id matches Python implementation."""
|
||||||
|
|
||||||
|
test_results: dict[str, str] = {}
|
||||||
|
all_tests_complete = asyncio.Event()
|
||||||
|
expected_tests = {
|
||||||
|
"foo",
|
||||||
|
"upper",
|
||||||
|
"space",
|
||||||
|
"underscore",
|
||||||
|
"hyphen",
|
||||||
|
"special",
|
||||||
|
"complex",
|
||||||
|
"empty",
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_log_line(line: str) -> None:
|
||||||
|
"""Capture log lines with test results."""
|
||||||
|
# Strip ANSI escape codes
|
||||||
|
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||||
|
# Look for our test result messages
|
||||||
|
# Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED"
|
||||||
|
match = re.search(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line)
|
||||||
|
if match:
|
||||||
|
test_name = match.group(1)
|
||||||
|
result = match.group(2)
|
||||||
|
test_results[test_name] = result
|
||||||
|
if set(test_results.keys()) >= expected_tests:
|
||||||
|
all_tests_complete.set()
|
||||||
|
|
||||||
|
async with (
|
||||||
|
run_compiled(yaml_config, line_callback=on_log_line),
|
||||||
|
api_client_connected() as client,
|
||||||
|
):
|
||||||
|
device_info = await client.device_info()
|
||||||
|
assert device_info is not None
|
||||||
|
assert device_info.name == "fnv1-hash-object-id-test"
|
||||||
|
|
||||||
|
# Wait for all tests to complete or timeout
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(all_tests_complete.wait(), timeout=2.0)
|
||||||
|
except TimeoutError:
|
||||||
|
pytest.fail(f"Tests timed out. Got results for: {set(test_results.keys())}")
|
||||||
|
|
||||||
|
# Verify all tests passed
|
||||||
|
for test_name in expected_tests:
|
||||||
|
assert test_name in test_results, f"{test_name} test not found"
|
||||||
|
assert test_results[test_name] == "PASSED", (
|
||||||
|
f"{test_name} test failed - C++ and Python hash mismatch"
|
||||||
|
)
|
||||||
176
tests/integration/test_object_id_api_verification.py
Normal file
176
tests/integration/test_object_id_api_verification.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Integration test to verify object_id from API matches Python computation.
|
||||||
|
|
||||||
|
This test verifies a three-way match between:
|
||||||
|
1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char)
|
||||||
|
2. C++ hash generation (fnv1_hash_object_id in helpers.h)
|
||||||
|
3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id)
|
||||||
|
|
||||||
|
The API response contains C++ computed values, so verifying API == Python
|
||||||
|
implicitly verifies C++ == Python == API for both object_id and hash.
|
||||||
|
|
||||||
|
This is important for the planned migration to remove object_id from the API
|
||||||
|
protocol and have clients (like aioesphomeapi) compute it from the name.
|
||||||
|
See: https://github.com/esphome/backlog/issues/76
|
||||||
|
|
||||||
|
Test cases covered:
|
||||||
|
- Named entities with various characters (uppercase, special chars, hyphens, etc.)
|
||||||
|
- Empty-name entities on main device (uses device's friendly_name with MAC suffix)
|
||||||
|
- Empty-name entities on sub-devices (uses sub-device's name)
|
||||||
|
- Named entities on sub-devices (uses entity name, not device name)
|
||||||
|
- MAC suffix handling (name_add_mac_suffix modifies friendly_name at runtime)
|
||||||
|
- Both object_id string and hash (key) verification
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome.helpers import fnv1_hash_object_id
|
||||||
|
|
||||||
|
from .entity_utils import compute_object_id, verify_all_entities
|
||||||
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||||
|
|
||||||
|
# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679"
|
||||||
|
MAC_SUFFIX = "abf679"
|
||||||
|
|
||||||
|
|
||||||
|
# Expected entities with their own names and expected object_ids
|
||||||
|
# Format: (entity_name, expected_object_id)
|
||||||
|
NAMED_ENTITIES = [
|
||||||
|
# sensor platform
|
||||||
|
("Temperature Sensor", "temperature_sensor"),
|
||||||
|
("UPPERCASE NAME", "uppercase_name"),
|
||||||
|
("Special!@Chars#", "special__chars_"),
|
||||||
|
("Temp-Sensor", "temp-sensor"),
|
||||||
|
("Temp_Sensor", "temp_sensor"),
|
||||||
|
("Living Room Temperature", "living_room_temperature"),
|
||||||
|
# binary_sensor platform
|
||||||
|
("Door Open", "door_open"),
|
||||||
|
("Sensor 123", "sensor_123"),
|
||||||
|
# switch platform
|
||||||
|
("My Very Long Switch Name Here", "my_very_long_switch_name_here"),
|
||||||
|
# text_sensor platform
|
||||||
|
("123 Start", "123_start"),
|
||||||
|
# button platform - named entity on sub-device (uses entity name, not device name)
|
||||||
|
("Device Button", "device_button"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sub-device names and their expected object_ids for empty-name entities
|
||||||
|
# Format: (device_name, expected_object_id)
|
||||||
|
SUB_DEVICE_EMPTY_NAME_ENTITIES = [
|
||||||
|
("Sub Device One", "sub_device_one"),
|
||||||
|
("Sub Device Two", "sub_device_two"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_object_id_api_verification(
|
||||||
|
yaml_config: str,
|
||||||
|
run_compiled: RunCompiledFunction,
|
||||||
|
api_client_connected: APIClientConnectedFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test that object_id from API matches Python computation.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
1. Named entities - object_id computed from entity name
|
||||||
|
2. Empty-name entities - object_id computed from friendly_name (with MAC suffix)
|
||||||
|
3. Hash verification - key can be computed from name
|
||||||
|
4. Generic verification - all entities can have object_id computed from API data
|
||||||
|
"""
|
||||||
|
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||||
|
# Get device info
|
||||||
|
device_info = await client.device_info()
|
||||||
|
assert device_info is not None
|
||||||
|
|
||||||
|
# Device name should include MAC suffix (hyphen separator)
|
||||||
|
assert device_info.name == f"object-id-test-{MAC_SUFFIX}", (
|
||||||
|
f"Device name mismatch: got '{device_info.name}'"
|
||||||
|
)
|
||||||
|
# Friendly name should include MAC suffix (space separator)
|
||||||
|
expected_friendly_name = f"Test Device {MAC_SUFFIX}"
|
||||||
|
assert device_info.friendly_name == expected_friendly_name, (
|
||||||
|
f"Friendly name mismatch: got '{device_info.friendly_name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all entities
|
||||||
|
entities, _ = await client.list_entities_services()
|
||||||
|
|
||||||
|
# Create a map of entity names to entity info
|
||||||
|
entity_map = {}
|
||||||
|
for entity in entities:
|
||||||
|
entity_map[entity.name] = entity
|
||||||
|
|
||||||
|
# === Test 1: Verify each named entity ===
|
||||||
|
for entity_name, expected_object_id in NAMED_ENTITIES:
|
||||||
|
assert entity_name in entity_map, (
|
||||||
|
f"Entity '{entity_name}' not found in API response. "
|
||||||
|
f"Available: {list(entity_map.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
entity = entity_map[entity_name]
|
||||||
|
|
||||||
|
# Verify object_id matches expected
|
||||||
|
assert entity.object_id == expected_object_id, (
|
||||||
|
f"Entity '{entity_name}': object_id mismatch. "
|
||||||
|
f"API returned '{entity.object_id}', expected '{expected_object_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify Python computation matches
|
||||||
|
computed = compute_object_id(entity_name)
|
||||||
|
assert computed == expected_object_id, (
|
||||||
|
f"Entity '{entity_name}': Python computation mismatch. "
|
||||||
|
f"Computed '{computed}', expected '{expected_object_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify hash can be computed from the name
|
||||||
|
hash_from_name = fnv1_hash_object_id(entity_name)
|
||||||
|
assert hash_from_name == entity.key, (
|
||||||
|
f"Entity '{entity_name}': hash mismatch. "
|
||||||
|
f"Python hash {hash_from_name:#x}, API key {entity.key:#x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# === Test 2: Verify empty-name entities ===
|
||||||
|
# Empty-name entities have name="" in API, object_id comes from:
|
||||||
|
# - Main device: friendly_name (with MAC suffix)
|
||||||
|
# - Sub-device: device name
|
||||||
|
|
||||||
|
# Get all empty-name entities
|
||||||
|
empty_name_entities = [e for e in entities if e.name == ""]
|
||||||
|
# We expect 3: 1 on main device, 2 on sub-devices
|
||||||
|
assert len(empty_name_entities) == 3, (
|
||||||
|
f"Expected 3 empty-name entities, got {len(empty_name_entities)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build device_id -> device_name map from device_info
|
||||||
|
device_id_to_name = {d.device_id: d.name for d in device_info.devices}
|
||||||
|
|
||||||
|
# Verify each empty-name entity
|
||||||
|
for entity in empty_name_entities:
|
||||||
|
if entity.device_id == 0:
|
||||||
|
# Main device - uses friendly_name with MAC suffix
|
||||||
|
expected_name = expected_friendly_name
|
||||||
|
else:
|
||||||
|
# Sub-device - uses device name
|
||||||
|
assert entity.device_id in device_id_to_name, (
|
||||||
|
f"Entity device_id {entity.device_id} not found in devices"
|
||||||
|
)
|
||||||
|
expected_name = device_id_to_name[entity.device_id]
|
||||||
|
|
||||||
|
expected_object_id = compute_object_id(expected_name)
|
||||||
|
assert entity.object_id == expected_object_id, (
|
||||||
|
f"Empty-name entity (device_id={entity.device_id}): object_id mismatch. "
|
||||||
|
f"API: '{entity.object_id}', expected: '{expected_object_id}' "
|
||||||
|
f"(from name '{expected_name}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify hash matches
|
||||||
|
expected_hash = fnv1_hash_object_id(expected_name)
|
||||||
|
assert entity.key == expected_hash, (
|
||||||
|
f"Empty-name entity (device_id={entity.device_id}): hash mismatch. "
|
||||||
|
f"API key: {entity.key:#x}, expected: {expected_hash:#x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# === Test 3: Verify ALL entities using the algorithm from entity_utils ===
|
||||||
|
# This uses the algorithm that aioesphomeapi will use to compute object_id
|
||||||
|
# client-side from API data.
|
||||||
|
verify_all_entities(entities, device_info)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Integration test for object_id with friendly_name but no MAC suffix.
|
||||||
|
|
||||||
|
This test covers Branch 4 of the algorithm:
|
||||||
|
- Empty name on main device
|
||||||
|
- NO MAC suffix enabled
|
||||||
|
- friendly_name IS set
|
||||||
|
- Result: use friendly_name for object_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome.helpers import fnv1_hash_object_id
|
||||||
|
|
||||||
|
from .entity_utils import (
|
||||||
|
compute_object_id,
|
||||||
|
infer_name_add_mac_suffix,
|
||||||
|
verify_all_entities,
|
||||||
|
)
|
||||||
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_object_id_friendly_name_no_mac_suffix(
|
||||||
|
yaml_config: str,
|
||||||
|
run_compiled: RunCompiledFunction,
|
||||||
|
api_client_connected: APIClientConnectedFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test object_id when friendly_name is set but no MAC suffix.
|
||||||
|
|
||||||
|
This covers Branch 4 of the algorithm:
|
||||||
|
- Empty name entity
|
||||||
|
- name_add_mac_suffix = false (or not set)
|
||||||
|
- friendly_name = "My Friendly Device"
|
||||||
|
- Expected: object_id = "my_friendly_device"
|
||||||
|
"""
|
||||||
|
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||||
|
device_info = await client.device_info()
|
||||||
|
assert device_info is not None
|
||||||
|
|
||||||
|
# Device name should NOT include MAC suffix
|
||||||
|
assert device_info.name == "test-device"
|
||||||
|
|
||||||
|
# Friendly name should be set
|
||||||
|
assert device_info.friendly_name == "My Friendly Device"
|
||||||
|
|
||||||
|
entities, _ = await client.list_entities_services()
|
||||||
|
|
||||||
|
# Find the empty-name entity
|
||||||
|
empty_name_entities = [e for e in entities if e.name == ""]
|
||||||
|
assert len(empty_name_entities) == 1
|
||||||
|
|
||||||
|
entity = empty_name_entities[0]
|
||||||
|
|
||||||
|
# Should use friendly_name for object_id (Branch 4)
|
||||||
|
expected_object_id = compute_object_id("My Friendly Device")
|
||||||
|
assert expected_object_id == "my_friendly_device" # Verify our expectation
|
||||||
|
assert entity.object_id == expected_object_id, (
|
||||||
|
f"Expected object_id '{expected_object_id}' from friendly_name, "
|
||||||
|
f"got '{entity.object_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hash should match friendly_name
|
||||||
|
expected_hash = fnv1_hash_object_id("My Friendly Device")
|
||||||
|
assert entity.key == expected_hash, (
|
||||||
|
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Named entity should work normally
|
||||||
|
named_entities = [e for e in entities if e.name == "Temperature"]
|
||||||
|
assert len(named_entities) == 1
|
||||||
|
assert named_entities[0].object_id == "temperature"
|
||||||
|
|
||||||
|
# Verify our inference: no MAC suffix in this test
|
||||||
|
assert not infer_name_add_mac_suffix(device_info), (
|
||||||
|
"Device name should NOT have MAC suffix"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the full algorithm from entity_utils works for ALL entities
|
||||||
|
verify_all_entities(entities, device_info)
|
||||||
140
tests/integration/test_object_id_no_friendly_name.py
Normal file
140
tests/integration/test_object_id_no_friendly_name.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Integration tests for object_id when friendly_name is not set.
|
||||||
|
|
||||||
|
These tests verify bug-for-bug compatibility with the old behavior:
|
||||||
|
|
||||||
|
1. With MAC suffix enabled + no friendly_name:
|
||||||
|
- OLD: is_object_id_dynamic_() was true, used App.get_friendly_name() directly
|
||||||
|
- OLD: object_id = "" (empty) because friendly_name was empty
|
||||||
|
- NEW: Must maintain same behavior for compatibility
|
||||||
|
|
||||||
|
2. Without MAC suffix + no friendly_name:
|
||||||
|
- OLD: is_object_id_dynamic_() was false, used pre-computed object_id_c_str_
|
||||||
|
- OLD: Python computed object_id with fallback to device name
|
||||||
|
- NEW: Must maintain same behavior (object_id = device name)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from esphome.helpers import fnv1_hash_object_id
|
||||||
|
|
||||||
|
from .entity_utils import compute_object_id, verify_all_entities
|
||||||
|
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||||
|
|
||||||
|
# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679"
|
||||||
|
MAC_SUFFIX = "abf679"
|
||||||
|
|
||||||
|
# FNV1 offset basis - hash of empty string
|
||||||
|
FNV1_OFFSET_BASIS = 2166136261
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_object_id_no_friendly_name_with_mac_suffix(
|
||||||
|
yaml_config: str,
|
||||||
|
run_compiled: RunCompiledFunction,
|
||||||
|
api_client_connected: APIClientConnectedFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test object_id when friendly_name not set but MAC suffix enabled.
|
||||||
|
|
||||||
|
OLD behavior (bug-for-bug compatibility):
|
||||||
|
- is_object_id_dynamic_() returned true (no own name AND mac suffix enabled)
|
||||||
|
- format_dynamic_object_id() used App.get_friendly_name() directly
|
||||||
|
- Since friendly_name was empty, object_id was empty
|
||||||
|
|
||||||
|
This was arguably a bug, but we maintain it for compatibility.
|
||||||
|
"""
|
||||||
|
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||||
|
device_info = await client.device_info()
|
||||||
|
assert device_info is not None
|
||||||
|
|
||||||
|
# Device name should include MAC suffix
|
||||||
|
expected_device_name = f"test-device-{MAC_SUFFIX}"
|
||||||
|
assert device_info.name == expected_device_name
|
||||||
|
|
||||||
|
# Friendly name should be empty (not set in config)
|
||||||
|
assert device_info.friendly_name == ""
|
||||||
|
|
||||||
|
entities, _ = await client.list_entities_services()
|
||||||
|
|
||||||
|
# Find the empty-name entity
|
||||||
|
empty_name_entities = [e for e in entities if e.name == ""]
|
||||||
|
assert len(empty_name_entities) == 1
|
||||||
|
|
||||||
|
entity = empty_name_entities[0]
|
||||||
|
|
||||||
|
# OLD behavior: object_id was empty because App.get_friendly_name() was empty
|
||||||
|
# This is bug-for-bug compatibility
|
||||||
|
assert entity.object_id == "", (
|
||||||
|
f"Expected empty object_id for bug-for-bug compatibility, "
|
||||||
|
f"got '{entity.object_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hash should be FNV1_OFFSET_BASIS (hash of empty string)
|
||||||
|
assert entity.key == FNV1_OFFSET_BASIS, (
|
||||||
|
f"Expected hash of empty string ({FNV1_OFFSET_BASIS:#x}), "
|
||||||
|
f"got {entity.key:#x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Named entity should work normally
|
||||||
|
named_entities = [e for e in entities if e.name == "Temperature"]
|
||||||
|
assert len(named_entities) == 1
|
||||||
|
assert named_entities[0].object_id == "temperature"
|
||||||
|
|
||||||
|
# Verify the full algorithm from entity_utils works for ALL entities
|
||||||
|
verify_all_entities(entities, device_info)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_object_id_no_friendly_name_no_mac_suffix(
|
||||||
|
yaml_config: str,
|
||||||
|
run_compiled: RunCompiledFunction,
|
||||||
|
api_client_connected: APIClientConnectedFactory,
|
||||||
|
) -> None:
|
||||||
|
"""Test object_id when friendly_name not set and no MAC suffix.
|
||||||
|
|
||||||
|
OLD behavior:
|
||||||
|
- is_object_id_dynamic_() returned false (mac suffix not enabled)
|
||||||
|
- Used object_id_c_str_ which was pre-computed in Python
|
||||||
|
- Python used get_base_entity_object_id() with fallback to CORE.name
|
||||||
|
|
||||||
|
Result: object_id = sanitize(snake_case(device_name))
|
||||||
|
"""
|
||||||
|
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||||
|
device_info = await client.device_info()
|
||||||
|
assert device_info is not None
|
||||||
|
|
||||||
|
# Device name should NOT include MAC suffix
|
||||||
|
assert device_info.name == "test-device"
|
||||||
|
|
||||||
|
# Friendly name should be empty (not set in config)
|
||||||
|
assert device_info.friendly_name == ""
|
||||||
|
|
||||||
|
entities, _ = await client.list_entities_services()
|
||||||
|
|
||||||
|
# Find the empty-name entity
|
||||||
|
empty_name_entities = [e for e in entities if e.name == ""]
|
||||||
|
assert len(empty_name_entities) == 1
|
||||||
|
|
||||||
|
entity = empty_name_entities[0]
|
||||||
|
|
||||||
|
# OLD behavior: object_id was computed from device name
|
||||||
|
expected_object_id = compute_object_id("test-device")
|
||||||
|
assert entity.object_id == expected_object_id, (
|
||||||
|
f"Expected object_id '{expected_object_id}' from device name, "
|
||||||
|
f"got '{entity.object_id}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hash should match device name
|
||||||
|
expected_hash = fnv1_hash_object_id("test-device")
|
||||||
|
assert entity.key == expected_hash, (
|
||||||
|
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Named entity should work normally
|
||||||
|
named_entities = [e for e in entities if e.name == "Temperature"]
|
||||||
|
assert len(named_entities) == 1
|
||||||
|
assert named_entities[0].object_id == "temperature"
|
||||||
|
|
||||||
|
# Verify the full algorithm from entity_utils works for ALL entities
|
||||||
|
verify_all_entities(entities, device_info)
|
||||||
@@ -27,13 +27,9 @@ from esphome.helpers import sanitize, snake_case
|
|||||||
|
|
||||||
from .common import load_config_from_fixture
|
from .common import load_config_from_fixture
|
||||||
|
|
||||||
# Pre-compiled regex patterns for extracting object IDs from expressions
|
# Pre-compiled regex pattern for extracting names from set_name calls
|
||||||
# Matches both old format: .set_object_id("obj_id")
|
# Matches: .set_name("name", hash) or .set_name("name")
|
||||||
# and new format: .set_name_and_object_id("name", "obj_id")
|
SET_NAME_PATTERN = re.compile(r'\.set_name\(["\']([^"\']*)["\']')
|
||||||
OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)')
|
|
||||||
COMBINED_PATTERN = re.compile(
|
|
||||||
r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)'
|
|
||||||
)
|
|
||||||
|
|
||||||
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers"
|
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers"
|
||||||
|
|
||||||
@@ -276,14 +272,21 @@ def setup_test_environment() -> Generator[list[str], None, None]:
|
|||||||
|
|
||||||
|
|
||||||
def extract_object_id_from_expressions(expressions: list[str]) -> str | None:
|
def extract_object_id_from_expressions(expressions: list[str]) -> str | None:
|
||||||
"""Extract the object ID that was set from the generated expressions."""
|
"""Extract the object ID that would be computed from set_name calls.
|
||||||
|
|
||||||
|
Since object_id is now computed from the name (via snake_case + sanitize),
|
||||||
|
we extract the name from set_name() calls and compute the expected object_id.
|
||||||
|
For empty names, we fall back to CORE.friendly_name or CORE.name.
|
||||||
|
"""
|
||||||
for expr in expressions:
|
for expr in expressions:
|
||||||
# First try new combined format: .set_name_and_object_id("name", "obj_id")
|
if match := SET_NAME_PATTERN.search(expr):
|
||||||
if match := COMBINED_PATTERN.search(expr):
|
name = match.group(1)
|
||||||
return match.group(1)
|
if name:
|
||||||
# Fall back to old format: .set_object_id("obj_id")
|
return sanitize(snake_case(name))
|
||||||
if match := OBJECT_ID_PATTERN.search(expr):
|
# Empty name - fall back to friendly_name or device name
|
||||||
return match.group(1)
|
if CORE.friendly_name:
|
||||||
|
return sanitize(snake_case(CORE.friendly_name))
|
||||||
|
return sanitize(snake_case(CORE.name)) if CORE.name else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -757,3 +760,140 @@ def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None:
|
|||||||
r"Each entity on a device must have a unique name within its platform\.$",
|
r"Each entity on a device must have a unique name within its platform\.$",
|
||||||
):
|
):
|
||||||
validator(config2)
|
validator(config2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setup_entity_empty_name_with_device(
|
||||||
|
setup_test_environment: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Test setup_entity with empty entity name on a sub-device.
|
||||||
|
|
||||||
|
For empty-name entities, Python passes 0 and C++ calculates the hash
|
||||||
|
at runtime from the device's actual name.
|
||||||
|
"""
|
||||||
|
added_expressions = setup_test_environment
|
||||||
|
|
||||||
|
# Mock get_variable to return a mock device
|
||||||
|
original_get_variable = entity_helpers.get_variable
|
||||||
|
|
||||||
|
async def mock_get_variable(id_: ID) -> MockObj:
|
||||||
|
return MockObj("sub_device_1")
|
||||||
|
|
||||||
|
entity_helpers.get_variable = mock_get_variable
|
||||||
|
|
||||||
|
var = MockObj("sensor1")
|
||||||
|
device_id = ID("sub_device_1", type="Device")
|
||||||
|
|
||||||
|
config = {
|
||||||
|
CONF_NAME: "",
|
||||||
|
CONF_DISABLED_BY_DEFAULT: False,
|
||||||
|
CONF_DEVICE_ID: device_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
await setup_entity(var, config, "sensor")
|
||||||
|
|
||||||
|
entity_helpers.get_variable = original_get_variable
|
||||||
|
|
||||||
|
# Check that set_device was called
|
||||||
|
assert any("sensor1.set_device" in expr for expr in added_expressions)
|
||||||
|
|
||||||
|
# For empty-name entities, Python passes 0 - C++ calculates hash at runtime
|
||||||
|
assert any('set_name("", 0)' in expr for expr in added_expressions), (
|
||||||
|
f"Expected set_name with hash 0, got {added_expressions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setup_entity_empty_name_with_mac_suffix(
|
||||||
|
setup_test_environment: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Test setup_entity with empty name and MAC suffix enabled.
|
||||||
|
|
||||||
|
For empty-name entities, Python passes 0 and C++ calculates the hash
|
||||||
|
at runtime from friendly_name (bug-for-bug compatibility).
|
||||||
|
"""
|
||||||
|
added_expressions = setup_test_environment
|
||||||
|
|
||||||
|
# Set up CORE.config with name_add_mac_suffix enabled
|
||||||
|
CORE.config = {"name_add_mac_suffix": True}
|
||||||
|
# Set friendly_name to a specific value
|
||||||
|
CORE.friendly_name = "My Device"
|
||||||
|
|
||||||
|
var = MockObj("sensor1")
|
||||||
|
|
||||||
|
config = {
|
||||||
|
CONF_NAME: "",
|
||||||
|
CONF_DISABLED_BY_DEFAULT: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
await setup_entity(var, config, "sensor")
|
||||||
|
|
||||||
|
# For empty-name entities, Python passes 0 - C++ calculates hash at runtime
|
||||||
|
assert any('set_name("", 0)' in expr for expr in added_expressions), (
|
||||||
|
f"Expected set_name with hash 0, got {added_expressions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name(
|
||||||
|
setup_test_environment: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Test setup_entity with empty name, MAC suffix enabled, but no friendly_name.
|
||||||
|
|
||||||
|
For empty-name entities, Python passes 0 and C++ calculates the hash
|
||||||
|
at runtime. In this case C++ will hash the empty friendly_name
|
||||||
|
(bug-for-bug compatibility).
|
||||||
|
"""
|
||||||
|
added_expressions = setup_test_environment
|
||||||
|
|
||||||
|
# Set up CORE.config with name_add_mac_suffix enabled
|
||||||
|
CORE.config = {"name_add_mac_suffix": True}
|
||||||
|
# Set friendly_name to empty
|
||||||
|
CORE.friendly_name = ""
|
||||||
|
|
||||||
|
var = MockObj("sensor1")
|
||||||
|
|
||||||
|
config = {
|
||||||
|
CONF_NAME: "",
|
||||||
|
CONF_DISABLED_BY_DEFAULT: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
await setup_entity(var, config, "sensor")
|
||||||
|
|
||||||
|
# For empty-name entities, Python passes 0 - C++ calculates hash at runtime
|
||||||
|
assert any('set_name("", 0)' in expr for expr in added_expressions), (
|
||||||
|
f"Expected set_name with hash 0, got {added_expressions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name(
|
||||||
|
setup_test_environment: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Test setup_entity with empty name, no MAC suffix, and no friendly_name.
|
||||||
|
|
||||||
|
For empty-name entities, Python passes 0 and C++ calculates the hash
|
||||||
|
at runtime from the device name.
|
||||||
|
"""
|
||||||
|
added_expressions = setup_test_environment
|
||||||
|
|
||||||
|
# No MAC suffix (either not set or False)
|
||||||
|
CORE.config = {}
|
||||||
|
# No friendly_name
|
||||||
|
CORE.friendly_name = ""
|
||||||
|
# Device name is set
|
||||||
|
CORE.name = "my-test-device"
|
||||||
|
|
||||||
|
var = MockObj("sensor1")
|
||||||
|
|
||||||
|
config = {
|
||||||
|
CONF_NAME: "",
|
||||||
|
CONF_DISABLED_BY_DEFAULT: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
await setup_entity(var, config, "sensor")
|
||||||
|
|
||||||
|
# For empty-name entities, Python passes 0 - C++ calculates hash at runtime
|
||||||
|
assert any('set_name("", 0)' in expr for expr in added_expressions), (
|
||||||
|
f"Expected set_name with hash 0, got {added_expressions}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -279,6 +279,77 @@ def test_sanitize(text, expected):
|
|||||||
assert actual == expected
|
assert actual == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("name", "expected_hash"),
|
||||||
|
(
|
||||||
|
# Basic strings - hash of sanitize(snake_case(name))
|
||||||
|
("foo", 0x408F5E13),
|
||||||
|
("Foo", 0x408F5E13), # Same as "foo" (lowercase)
|
||||||
|
("FOO", 0x408F5E13), # Same as "foo" (lowercase)
|
||||||
|
# Spaces become underscores
|
||||||
|
("foo bar", 0x3AE35AA1), # Transforms to "foo_bar"
|
||||||
|
("Foo Bar", 0x3AE35AA1), # Same (lowercase + underscore)
|
||||||
|
# Already snake_case
|
||||||
|
("foo_bar", 0x3AE35AA1),
|
||||||
|
# Special chars become underscores
|
||||||
|
("foo!bar", 0x3AE35AA1), # Transforms to "foo_bar"
|
||||||
|
("foo@bar", 0x3AE35AA1), # Transforms to "foo_bar"
|
||||||
|
# Hyphens are preserved
|
||||||
|
("foo-bar", 0x438B12E3),
|
||||||
|
# Numbers are preserved
|
||||||
|
("foo123", 0xF3B0067D),
|
||||||
|
# Empty string
|
||||||
|
("", 0x811C9DC5), # FNV1_OFFSET_BASIS (no chars processed)
|
||||||
|
# Single char
|
||||||
|
("a", 0x050C5D7E),
|
||||||
|
# Mixed case and spaces
|
||||||
|
("My Sensor Name", 0x2760962A), # Transforms to "my_sensor_name"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_fnv1_hash_object_id(name, expected_hash):
|
||||||
|
"""Test fnv1_hash_object_id produces expected hashes.
|
||||||
|
|
||||||
|
These expected values were computed to match the C++ implementation
|
||||||
|
in esphome/core/helpers.h. If this test fails after modifying either
|
||||||
|
implementation, ensure both Python and C++ versions stay in sync.
|
||||||
|
"""
|
||||||
|
actual = helpers.fnv1_hash_object_id(name)
|
||||||
|
|
||||||
|
assert actual == expected_hash
|
||||||
|
|
||||||
|
|
||||||
|
def _fnv1_hash_py(s: str) -> int:
|
||||||
|
"""Python implementation of FNV-1 hash for verification."""
|
||||||
|
hash_val = 2166136261 # FNV1_OFFSET_BASIS
|
||||||
|
for c in s:
|
||||||
|
hash_val = (hash_val * 16777619) & 0xFFFFFFFF # FNV1_PRIME
|
||||||
|
hash_val ^= ord(c)
|
||||||
|
return hash_val
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name",
|
||||||
|
(
|
||||||
|
"Simple",
|
||||||
|
"With Space",
|
||||||
|
"MixedCase",
|
||||||
|
"special!@#chars",
|
||||||
|
"already_snake_case",
|
||||||
|
"123numbers",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_fnv1_hash_object_id_matches_manual_calculation(name):
|
||||||
|
"""Verify fnv1_hash_object_id matches snake_case + sanitize + standard FNV-1."""
|
||||||
|
# Manual calculation: snake_case -> sanitize -> fnv1_hash
|
||||||
|
transformed = helpers.sanitize(helpers.snake_case(name))
|
||||||
|
expected = _fnv1_hash_py(transformed)
|
||||||
|
|
||||||
|
# Direct calculation via fnv1_hash_object_id
|
||||||
|
actual = helpers.fnv1_hash_object_id(name)
|
||||||
|
|
||||||
|
assert actual == expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"text, expected",
|
"text, expected",
|
||||||
((["127.0.0.1", "fe80::1", "2001::2"], ["2001::2", "127.0.0.1", "fe80::1"]),),
|
((["127.0.0.1", "fe80::1", "2001::2"], ["2001::2", "127.0.0.1", "fe80::1"]),),
|
||||||
|
|||||||
Reference in New Issue
Block a user