diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 3c35076de9..6d37d53a4c 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -14,8 +14,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@grahambrown11", "@hwstar"] IS_PLATFORM_COMPONENT = True @@ -149,6 +149,9 @@ _ALARM_CONTROL_PANEL_SCHEMA = ( ) +_ALARM_CONTROL_PANEL_SCHEMA.add_extra(entity_duplicate_validator("alarm_control_panel")) + + def alarm_control_panel_schema( class_: MockObjClass, *, diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index b34477d30a..fd9551b850 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -60,8 +60,8 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -491,6 +491,9 @@ _BINARY_SENSOR_SCHEMA = ( ) +_BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor")) + + def binary_sensor_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index c63073dd38..ed2670a5c5 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -18,8 +18,8 @@ from esphome.const import ( DEVICE_CLASS_UPDATE, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -61,6 +61,9 @@ _BUTTON_SCHEMA = ( ) +_BUTTON_SCHEMA.add_extra(entity_duplicate_validator("button")) + + def button_schema( class_: MockObjClass, *, diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index ff00565abf..9530ecdcca 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -48,8 +48,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity IS_PLATFORM_COMPONENT = True @@ -247,6 +247,9 @@ _CLIMATE_SCHEMA = ( ) +_CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate")) + + def climate_schema( class_: MockObjClass, *, diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index c7aec6493b..cd97a38ecc 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -33,8 +33,8 @@ from esphome.const import ( DEVICE_CLASS_WINDOW, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity IS_PLATFORM_COMPONENT = True @@ -126,6 +126,9 @@ _COVER_SCHEMA = ( ) +_COVER_SCHEMA.add_extra(entity_duplicate_validator("cover")) + + def cover_schema( class_: MockObjClass, *, diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 42b29227c3..4788810965 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -22,8 +22,8 @@ from esphome.const import ( CONF_YEAR, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -84,6 +84,8 @@ _DATETIME_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) ).add_extra(_validate_time_present) +_DATETIME_SCHEMA.add_extra(entity_duplicate_validator("datetime")) + def date_schema(class_: MockObjClass) -> cv.Schema: schema = cv.Schema( diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 68ba1ae549..cfca0ed6fc 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -19,7 +19,7 @@ from esphome.const import ( CONF_VSYNC_PIN, ) from esphome.core import CORE -from esphome.cpp_helpers import setup_entity +from esphome.core.entity_helpers import setup_entity DEPENDENCIES = ["esp32"] diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 1ff0d4e3d5..3aff96a48e 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -18,8 +18,8 @@ from esphome.const import ( DEVICE_CLASS_MOTION, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@nohat"] IS_PLATFORM_COMPONENT = True @@ -59,6 +59,9 @@ _EVENT_SCHEMA = ( ) +_EVENT_SCHEMA.add_extra(entity_duplicate_validator("event")) + + def event_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index bebf760b0b..0b1d39575d 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -32,7 +32,7 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority -from esphome.cpp_helpers import setup_entity +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity IS_PLATFORM_COMPONENT = True @@ -161,6 +161,9 @@ _FAN_SCHEMA = ( ) +_FAN_SCHEMA.add_extra(entity_duplicate_validator("fan")) + + def fan_schema( class_: cg.Pvariable, *, diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 902d661eb5..7ab899edb2 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -38,8 +38,8 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity from .automation import LIGHT_STATE_SCHEMA from .effects import ( @@ -110,6 +110,8 @@ LIGHT_SCHEMA = ( ) ) +LIGHT_SCHEMA.add_extra(entity_duplicate_validator("light")) + BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( { cv.Optional(CONF_EFFECTS): validate_effects(BINARY_EFFECTS), diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index aa1061de53..e62d9f3e2b 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -14,8 +14,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -67,6 +67,9 @@ _LOCK_SCHEMA = ( ) +_LOCK_SCHEMA.add_extra(entity_duplicate_validator("lock")) + + def lock_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index 742b538938..7d8d13d8c4 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_SIZE, CONF_TEXT from esphome.cpp_generator import MockObjClass -from ..defines import CONF_MAIN, literal +from ..defines import CONF_MAIN from ..lv_validation import color, color_retmapper, lv_text from ..lvcode import LocalVariable, lv, lv_expr from ..schemas import TEXT_SCHEMA @@ -34,7 +34,7 @@ class QrCodeType(WidgetType): ) def get_uses(self): - return ("canvas", "img") + return ("canvas", "img", "label") def obj_creator(self, parent: MockObjClass, config: dict): dark_color = color_retmapper(config[CONF_DARK_COLOR]) @@ -45,10 +45,8 @@ class QrCodeType(WidgetType): async def to_code(self, w: Widget, config): if (value := config.get(CONF_TEXT)) is not None: value = await lv_text.process(value) - with LocalVariable( - "qr_text", cg.const_char_ptr, value, modifier="" - ) as str_obj: - lv.qrcode_update(w.obj, str_obj, literal(f"strlen({str_obj})")) + with LocalVariable("qr_text", cg.std_string, value, modifier="") as str_obj: + lv.qrcode_update(w.obj, str_obj.c_str(), str_obj.size()) qr_code_spec = QrCodeType() diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index c01bd24890..ccded1deb2 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -11,9 +11,9 @@ from esphome.const import ( CONF_VOLUME, ) from esphome.core import CORE +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.coroutine import coroutine_with_priority from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@jesserockz"] @@ -143,6 +143,8 @@ _MEDIA_PLAYER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( } ) +_MEDIA_PLAYER_SCHEMA.add_extra(entity_duplicate_validator("media_player")) + def media_player_schema( class_: MockObjClass, @@ -166,7 +168,6 @@ def media_player_schema( MEDIA_PLAYER_SCHEMA = media_player_schema(MediaPlayer) MEDIA_PLAYER_SCHEMA.add_extra(cv.deprecated_schema_constant("media_player")) - MEDIA_PLAYER_ACTION_SCHEMA = automation.maybe_simple_id( cv.Schema( { diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 63d8da5788..f0d5a95d43 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -68,6 +68,7 @@ def AUTO_LOAD(): CONF_DISCOVER_IP = "discover_ip" CONF_IDF_SEND_ASYNC = "idf_send_async" +CONF_WAIT_FOR_CONNECTION = "wait_for_connection" def validate_message_just_topic(value): @@ -298,6 +299,7 @@ CONFIG_SCHEMA = cv.All( } ), cv.Optional(CONF_PUBLISH_NAN_AS_NONE, default=False): cv.boolean, + cv.Optional(CONF_WAIT_FOR_CONNECTION, default=False): cv.boolean, } ), validate_config, @@ -453,6 +455,8 @@ async def to_code(config): cg.add(var.set_publish_nan_as_none(config[CONF_PUBLISH_NAN_AS_NONE])) + cg.add(var.set_wait_for_connection(config[CONF_WAIT_FOR_CONNECTION])) + MQTT_PUBLISH_ACTION_SCHEMA = cv.Schema( { diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index ceb56bdfbe..3ba1ac6077 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -176,7 +176,8 @@ void MQTTClientComponent::dump_config() { } } bool MQTTClientComponent::can_proceed() { - return network::is_disabled() || this->state_ == MQTT_CLIENT_DISABLED || this->is_connected(); + return network::is_disabled() || this->state_ == MQTT_CLIENT_DISABLED || this->is_connected() || + !this->wait_for_connection_; } void MQTTClientComponent::start_dnslookup_() { diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index c68b3c62eb..a95b122383 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -4,11 +4,11 @@ #ifdef USE_MQTT -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/core/log.h" #include "esphome/components/json/json_util.h" #include "esphome/components/network/ip_address.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/log.h" #if defined(USE_ESP32) #include "mqtt_backend_esp32.h" #elif defined(USE_ESP8266) @@ -267,6 +267,8 @@ class MQTTClientComponent : public Component { void set_publish_nan_as_none(bool publish_nan_as_none); bool is_publish_nan_as_none() const; + void set_wait_for_connection(bool wait_for_connection) { this->wait_for_connection_ = wait_for_connection; } + protected: void send_device_info_(); @@ -334,6 +336,7 @@ class MQTTClientComponent : public Component { optional disconnect_reason_{}; bool publish_nan_as_none_{false}; + bool wait_for_connection_{false}; }; extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 65a00bfe2f..4beed57188 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -76,8 +76,8 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ @@ -207,6 +207,9 @@ _NUMBER_SCHEMA = ( ) +_NUMBER_SCHEMA.add_extra(entity_duplicate_validator("number")) + + def number_schema( class_: MockObjClass, *, diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index c3f8abec8f..ed1f6c020d 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -17,8 +17,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -65,6 +65,9 @@ _SELECT_SCHEMA = ( ) +_SELECT_SCHEMA.add_extra(entity_duplicate_validator("select")) + + def select_schema( class_: MockObjClass, *, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 749b7992b8..ea74361d51 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -101,8 +101,8 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -318,6 +318,8 @@ _SENSOR_SCHEMA = ( ) ) +_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("sensor")) + def sensor_schema( class_: MockObjClass = cv.UNDEFINED, diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 322d547e95..c09675069f 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -20,8 +20,8 @@ from esphome.const import ( DEVICE_CLASS_SWITCH, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -91,6 +91,9 @@ _SWITCH_SCHEMA = ( ) +_SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch")) + + def switch_schema( class_: MockObjClass, *, diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index fc1b3d1b05..8362e09ac0 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -14,8 +14,8 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@mauritskorse"] IS_PLATFORM_COMPONENT = True @@ -58,6 +58,9 @@ _TEXT_SCHEMA = ( ) +_TEXT_SCHEMA.add_extra(entity_duplicate_validator("text")) + + def text_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 38f0ae451e..abb2dcae6c 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -21,8 +21,8 @@ from esphome.const import ( DEVICE_CLASS_TIMESTAMP, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity from esphome.util import Registry DEVICE_CLASSES = [ @@ -153,6 +153,9 @@ _TEXT_SENSOR_SCHEMA = ( ) +_TEXT_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("text_sensor")) + + def text_sensor_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index 061dd4589f..758267f412 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -15,8 +15,8 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity CODEOWNERS = ["@jesserockz"] IS_PLATFORM_COMPONENT = True @@ -58,6 +58,9 @@ _UPDATE_SCHEMA = ( ) +_UPDATE_SCHEMA.add_extra(entity_duplicate_validator("update")) + + def update_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 98c96f9afc..cb27546120 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -22,8 +22,8 @@ from esphome.const import ( DEVICE_CLASS_WATER, ) from esphome.core import CORE, coroutine_with_priority +from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass -from esphome.cpp_helpers import setup_entity IS_PLATFORM_COMPONENT = True @@ -103,6 +103,9 @@ _VALVE_SCHEMA = ( ) +_VALVE_SCHEMA.add_extra(entity_duplicate_validator("valve")) + + def valve_schema( class_: MockObjClass = cv.UNDEFINED, *, diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 00c1db33ee..368e2affe9 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -523,8 +523,8 @@ class EsphomeCore: # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count self.platform_counts: defaultdict[str, int] = defaultdict(int) # Track entity unique IDs to handle duplicates - # Key: (device_id, platform, object_id), Value: count of duplicates - self.unique_ids: dict[tuple[int, str, str], int] = {} + # Set of (device_id, platform, sanitized_name) tuples + self.unique_ids: set[tuple[str, str, str]] = set() # Whether ESPHome was started in verbose mode self.verbose = False # Whether ESPHome was started in quiet mode @@ -556,7 +556,7 @@ class EsphomeCore: self.loaded_integrations = set() self.component_ids = set() self.platform_counts = defaultdict(int) - self.unique_ids = {} + self.unique_ids = set() PIN_SCHEMA_REGISTRY.reset() @property diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 7f6a9b48ab..c95acebbf9 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,5 +1,116 @@ -from esphome.const import CONF_ID +from collections.abc import Callable +import logging + +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEVICE_ID, + CONF_DISABLED_BY_DEFAULT, + CONF_ENTITY_CATEGORY, + CONF_ICON, + CONF_ID, + CONF_INTERNAL, + CONF_NAME, +) +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, add, get_variable import esphome.final_validate as fv +from esphome.helpers import sanitize, snake_case +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + + +def get_base_entity_object_id( + name: str, friendly_name: str | None, device_name: str | None = None +) -> str: + """Calculate the base object ID for an entity that will be set via set_object_id(). + + This function calculates what object_id_c_str_ should be set to in C++. + + The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + - If !has_own_name && is_name_add_mac_suffix_enabled(): + return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic + - Else: + return object_id_c_str_ ?? "" // What we set via set_object_id() + + Since we're calculating what to pass to set_object_id(), we always need to + generate the object_id the same way, regardless of name_add_mac_suffix setting. + + Args: + name: The entity name (empty string if no name) + friendly_name: The friendly name from CORE.friendly_name + device_name: The device name if entity is on a sub-device + + Returns: + The base object ID to use for duplicate checking and to pass to set_object_id() + """ + + 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)) + + +async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: + """Set up generic properties of an Entity. + + This function sets up the common entity properties like name, icon, + entity category, etc. + + Args: + var: The entity variable to set up + config: Configuration dictionary containing entity settings + platform: The platform name (e.g., "sensor", "binary_sensor") + """ + # Get device info + device_name: str | None = None + if CONF_DEVICE_ID in config: + device_id_obj: ID = config[CONF_DEVICE_ID] + device: MockObj = await get_variable(device_id_obj) + add(var.set_device(device)) + # Get device name for object ID calculation + device_name = device_id_obj.id + + add(var.set_name(config[CONF_NAME])) + + # Calculate base object_id using the same logic as C++ + # This must match the C++ behavior in esphome/core/entity_base.cpp + base_object_id = get_base_entity_object_id( + config[CONF_NAME], CORE.friendly_name, device_name + ) + + if not config[CONF_NAME]: + _LOGGER.debug( + "Entity has empty name, using '%s' as object_id base", base_object_id + ) + + # Set the object ID + add(var.set_object_id(base_object_id)) + _LOGGER.debug( + "Setting object_id '%s' for entity '%s' on platform '%s'", + base_object_id, + config[CONF_NAME], + platform, + ) + add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) + if CONF_INTERNAL in config: + add(var.set_internal(config[CONF_INTERNAL])) + if CONF_ICON in config: + add(var.set_icon(config[CONF_ICON])) + if CONF_ENTITY_CATEGORY in config: + add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) def inherit_property_from(property_to_inherit, parent_id_property, transform=None): @@ -54,3 +165,48 @@ def inherit_property_from(property_to_inherit, parent_id_property, transform=Non return config return inherit_property + + +def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigType]: + """Create a validator function to check for duplicate entity names. + + This validator is meant to be used with schema.add_extra() for entity base schemas. + + Args: + platform: The platform name (e.g., "sensor", "binary_sensor") + + Returns: + A validator function that checks for duplicate names + """ + + def validator(config: ConfigType) -> ConfigType: + if CONF_NAME not in config: + # No name to validate + return config + + # Get the entity name and device info + entity_name = config[CONF_NAME] + device_id = "" # Empty string for main device + + if CONF_DEVICE_ID in config: + device_id_obj = config[CONF_DEVICE_ID] + # Use the device ID string directly for uniqueness + device_id = device_id_obj.id + + # For duplicate detection, just use the sanitized name + name_key = sanitize(snake_case(entity_name)) + + # Check for duplicates + unique_key = (device_id, platform, name_key) + if unique_key in CORE.unique_ids: + device_prefix = f" on device '{device_id}'" if device_id else "" + raise cv.Invalid( + f"Duplicate {platform} entity with name '{entity_name}' found{device_prefix}. " + f"Each entity on a device must have a unique name within its platform." + ) + + # Add to tracking set + CORE.unique_ids.add(unique_key) + return config + + return validator diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 746a006348..3f64be6154 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -11,9 +11,6 @@ from esphome.core import CORE, ID, coroutine from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import add, get_variable from esphome.cpp_types import App -from esphome.entity import ( # noqa: F401 # pylint: disable=unused-import - setup_entity, # Import for backward compatibility -) from esphome.types import ConfigFragmentType, ConfigType from esphome.util import Registry, RegistryEntry diff --git a/esphome/entity.py b/esphome/entity.py deleted file mode 100644 index 3fa2d62b4d..0000000000 --- a/esphome/entity.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Entity-related helper functions.""" - -import logging - -from esphome.const import ( - CONF_DEVICE_ID, - CONF_DISABLED_BY_DEFAULT, - CONF_ENTITY_CATEGORY, - CONF_ICON, - CONF_INTERNAL, - CONF_NAME, -) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, add, get_variable -from esphome.helpers import fnv1a_32bit_hash, sanitize, snake_case -from esphome.types import ConfigType - -_LOGGER = logging.getLogger(__name__) - - -def get_base_entity_object_id( - name: str, friendly_name: str | None, device_name: str | None = None -) -> str: - """Calculate the base object ID for an entity that will be set via set_object_id(). - - This function calculates what object_id_c_str_ should be set to in C++. - - The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: - - If !has_own_name && is_name_add_mac_suffix_enabled(): - return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - - Else: - return object_id_c_str_ ?? "" // What we set via set_object_id() - - Since we're calculating what to pass to set_object_id(), we always need to - generate the object_id the same way, regardless of name_add_mac_suffix setting. - - Args: - name: The entity name (empty string if no name) - friendly_name: The friendly name from CORE.friendly_name - device_name: The device name if entity is on a sub-device - - Returns: - The base object ID to use for duplicate checking and to pass to set_object_id() - """ - - 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)) - - -async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: - """Set up generic properties of an Entity. - - This function handles duplicate entity names by automatically appending - a suffix (_2, _3, etc.) when multiple entities have the same object_id - within the same platform and device combination. - - Args: - var: The entity variable to set up - config: Configuration dictionary containing entity settings - platform: The platform name (e.g., "sensor", "binary_sensor") - """ - # Get device info - device_id: int = 0 - device_name: str | None = None - if CONF_DEVICE_ID in config: - device_id_obj: ID = config[CONF_DEVICE_ID] - device: MockObj = await get_variable(device_id_obj) - add(var.set_device(device)) - # Use the device's ID hash as device_id - - device_id = fnv1a_32bit_hash(device_id_obj.id) - # Get device name for object ID calculation - device_name = device_id_obj.id - - add(var.set_name(config[CONF_NAME])) - - # Calculate base object_id using the same logic as C++ - # This must match the C++ behavior in esphome/core/entity_base.cpp - base_object_id = get_base_entity_object_id( - config[CONF_NAME], CORE.friendly_name, device_name - ) - - if not config[CONF_NAME]: - _LOGGER.debug( - "Entity has empty name, using '%s' as object_id base", base_object_id - ) - - # Handle duplicates - # Check for duplicates - unique_key: tuple[int, str, str] = (device_id, platform, base_object_id) - if unique_key in CORE.unique_ids: - # Found duplicate, add suffix - count = CORE.unique_ids[unique_key] + 1 - CORE.unique_ids[unique_key] = count - object_id = f"{base_object_id}_{count}" - _LOGGER.info( - "Duplicate %s entity '%s' found. Renaming to '%s'", - platform, - config[CONF_NAME], - object_id, - ) - else: - # First occurrence - CORE.unique_ids[unique_key] = 1 - object_id = base_object_id - - add(var.set_object_id(object_id)) - _LOGGER.debug( - "Setting object_id '%s' for entity '%s' on platform '%s'", - object_id, - config[CONF_NAME], - platform, - ) - add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT])) - if CONF_INTERNAL in config: - add(var.set_internal(config[CONF_INTERNAL])) - if CONF_ICON in config: - add(var.set_icon(config[CONF_ICON])) - if CONF_ENTITY_CATEGORY in config: - add(var.set_entity_category(config[CONF_ENTITY_CATEGORY])) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index f32b09d0e6..212e30c1eb 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -646,7 +646,9 @@ lvgl: on_click: lvgl.qrcode.update: id: lv_qr - text: homeassistant.io + text: + format: "A string with a number %d" + args: ['(int)(random_uint32() % 1000)'] - slider: min_value: 0 diff --git a/tests/integration/fixtures/duplicate_entities.yaml b/tests/integration/fixtures/duplicate_entities.yaml deleted file mode 100644 index 17332fe4b2..0000000000 --- a/tests/integration/fixtures/duplicate_entities.yaml +++ /dev/null @@ -1,211 +0,0 @@ -esphome: - name: duplicate-entities-test - # Define devices to test multi-device duplicate handling - devices: - - id: controller_1 - name: Controller 1 - - id: controller_2 - name: Controller 2 - -host: -api: # Port will be automatically injected -logger: - -# Create duplicate entities across different scenarios - -# Scenario 1: Multiple sensors with same name on same device (should get _2, _3, _4) -sensor: - - platform: template - name: Temperature - lambda: return 1.0; - update_interval: 0.1s - - - platform: template - name: Temperature - lambda: return 2.0; - update_interval: 0.1s - - - platform: template - name: Temperature - lambda: return 3.0; - update_interval: 0.1s - - - platform: template - name: Temperature - lambda: return 4.0; - update_interval: 0.1s - - # Scenario 2: Device-specific duplicates using device_id configuration - - platform: template - name: Device Temperature - device_id: controller_1 - lambda: return 10.0; - update_interval: 0.1s - - - platform: template - name: Device Temperature - device_id: controller_1 - lambda: return 11.0; - update_interval: 0.1s - - - platform: template - name: Device Temperature - device_id: controller_1 - lambda: return 12.0; - update_interval: 0.1s - - # Different device, same name - should not conflict - - platform: template - name: Device Temperature - device_id: controller_2 - lambda: return 20.0; - update_interval: 0.1s - -# Scenario 3: Binary sensors (different platform, same name) -binary_sensor: - - platform: template - name: Temperature - lambda: return true; - - - platform: template - name: Temperature - lambda: return false; - - - platform: template - name: Temperature - lambda: return true; - - # Scenario 5: Binary sensors on devices - - platform: template - name: Device Temperature - device_id: controller_1 - lambda: return true; - - - platform: template - name: Device Temperature - device_id: controller_2 - lambda: return false; - - # Issue #6953: Empty names on binary sensors - - platform: template - name: "" - lambda: return true; - - platform: template - name: "" - lambda: return false; - - - platform: template - name: "" - lambda: return true; - - - platform: template - name: "" - lambda: return false; - -# Scenario 6: Test with special characters that need sanitization -text_sensor: - - platform: template - name: "Status Message!" - lambda: return {"status1"}; - update_interval: 0.1s - - - platform: template - name: "Status Message!" - lambda: return {"status2"}; - update_interval: 0.1s - - - platform: template - name: "Status Message!" - lambda: return {"status3"}; - update_interval: 0.1s - -# Scenario 7: More switch duplicates -switch: - - platform: template - name: "Power Switch" - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "Power Switch" - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - # Scenario 8: Issue #6953 - Multiple entities with empty names - # Empty names on main device - should use device name with suffixes - - platform: template - name: "" - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - # Scenario 9: Issue #6953 - Empty names on sub-devices - # Empty names on sub-device - should use sub-device name with suffixes - - platform: template - name: "" - device_id: controller_1 - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - device_id: controller_1 - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - device_id: controller_1 - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - # Empty names on different sub-device - - platform: template - name: "" - device_id: controller_2 - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "" - device_id: controller_2 - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - # Scenario 10: Issue #6953 - Duplicate "xyz" names - - platform: template - name: "xyz" - lambda: return false; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "xyz" - lambda: return true; - turn_on_action: [] - turn_off_action: [] - - - platform: template - name: "xyz" - lambda: return false; - turn_on_action: [] - turn_off_action: [] diff --git a/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml new file mode 100644 index 0000000000..ecc502ad28 --- /dev/null +++ b/tests/integration/fixtures/duplicate_entities_on_different_devices.yaml @@ -0,0 +1,154 @@ +esphome: + name: duplicate-entities-test + # Define devices to test multi-device duplicate handling + devices: + - id: controller_1 + name: Controller 1 + - id: controller_2 + name: Controller 2 + - id: controller_3 + name: Controller 3 + +host: +api: # Port will be automatically injected +logger: + +# Test that duplicate entity names are allowed on different devices + +# Scenario 1: Same sensor name on different devices (allowed) +sensor: + - platform: template + name: Temperature + device_id: controller_1 + lambda: return 21.0; + update_interval: 0.1s + + - platform: template + name: Temperature + device_id: controller_2 + lambda: return 22.0; + update_interval: 0.1s + + - platform: template + name: Temperature + device_id: controller_3 + lambda: return 23.0; + update_interval: 0.1s + + # Main device sensor (no device_id) + - platform: template + name: Temperature + lambda: return 20.0; + update_interval: 0.1s + + # Different sensor with unique name + - platform: template + name: Humidity + lambda: return 60.0; + update_interval: 0.1s + +# Scenario 2: Same binary sensor name on different devices (allowed) +binary_sensor: + - platform: template + name: Status + device_id: controller_1 + lambda: return true; + + - platform: template + name: Status + device_id: controller_2 + lambda: return false; + + - platform: template + name: Status + lambda: return true; # Main device + + # Different platform can have same name as sensor + - platform: template + name: Temperature + lambda: return true; + +# Scenario 3: Same text sensor name on different devices +text_sensor: + - platform: template + name: Device Info + device_id: controller_1 + lambda: return {"Controller 1 Active"}; + update_interval: 0.1s + + - platform: template + name: Device Info + device_id: controller_2 + lambda: return {"Controller 2 Active"}; + update_interval: 0.1s + + - platform: template + name: Device Info + lambda: return {"Main Device Active"}; + update_interval: 0.1s + +# Scenario 4: Same switch name on different devices +switch: + - platform: template + name: Power + device_id: controller_1 + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: Power + device_id: controller_2 + lambda: return true; + turn_on_action: [] + turn_off_action: [] + + - platform: template + name: Power + device_id: controller_3 + lambda: return false; + turn_on_action: [] + turn_off_action: [] + + # Unique switch on main device + - platform: template + name: Main Power + lambda: return true; + turn_on_action: [] + turn_off_action: [] + +# Scenario 5: Empty names on different devices (should use device name) +button: + - platform: template + name: "" + device_id: controller_1 + on_press: [] + + - platform: template + name: "" + device_id: controller_2 + on_press: [] + + - platform: template + name: "" + on_press: [] # Main device + +# Scenario 6: Special characters in names +number: + - platform: template + name: "Temperature Setpoint!" + device_id: controller_1 + min_value: 10.0 + max_value: 30.0 + step: 0.1 + lambda: return 21.0; + set_action: [] + + - platform: template + name: "Temperature Setpoint!" + device_id: controller_2 + min_value: 10.0 + max_value: 30.0 + step: 0.1 + lambda: return 22.0; + set_action: [] diff --git a/tests/integration/test_duplicate_entities.py b/tests/integration/test_duplicate_entities.py index 9b30d2db5a..99968204d4 100644 --- a/tests/integration/test_duplicate_entities.py +++ b/tests/integration/test_duplicate_entities.py @@ -1,4 +1,4 @@ -"""Integration test for duplicate entity handling.""" +"""Integration test for duplicate entity handling with new validation.""" from __future__ import annotations @@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction @pytest.mark.asyncio -async def test_duplicate_entities( +async def test_duplicate_entities_on_different_devices( yaml_config: str, run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that duplicate entity names are automatically suffixed with _2, _3, _4.""" + """Test that duplicate entity names are allowed on different devices.""" async with run_compiled(yaml_config), api_client_connected() as client: # Get device info device_info = await client.device_info() @@ -24,14 +24,16 @@ async def test_duplicate_entities( # Get devices devices = device_info.devices - assert len(devices) >= 2, f"Expected at least 2 devices, got {len(devices)}" + assert len(devices) >= 3, f"Expected at least 3 devices, got {len(devices)}" # Find our test devices controller_1 = next((d for d in devices if d.name == "Controller 1"), None) controller_2 = next((d for d in devices if d.name == "Controller 2"), None) + controller_3 = next((d for d in devices if d.name == "Controller 3"), None) assert controller_1 is not None, "Controller 1 device not found" assert controller_2 is not None, "Controller 2 device not found" + assert controller_3 is not None, "Controller 3 device not found" # Get entity list entities = await client.list_entities_services() @@ -48,203 +50,120 @@ async def test_duplicate_entities( e for e in all_entities if e.__class__.__name__ == "TextSensorInfo" ] switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"] + buttons = [e for e in all_entities if e.__class__.__name__ == "ButtonInfo"] + numbers = [e for e in all_entities if e.__class__.__name__ == "NumberInfo"] - # Scenario 1: Check sensors with duplicate "Temperature" names + # Scenario 1: Check sensors with same "Temperature" name on different devices temp_sensors = [s for s in sensors if s.name == "Temperature"] - temp_object_ids = sorted([s.object_id for s in temp_sensors]) - - # Should have temperature, temperature_2, temperature_3, temperature_4 - assert len(temp_object_ids) >= 4, ( - f"Expected at least 4 temperature sensors, got {len(temp_object_ids)}" - ) - assert "temperature" in temp_object_ids, ( - "First temperature sensor should not have suffix" - ) - assert "temperature_2" in temp_object_ids, ( - "Second temperature sensor should be temperature_2" - ) - assert "temperature_3" in temp_object_ids, ( - "Third temperature sensor should be temperature_3" - ) - assert "temperature_4" in temp_object_ids, ( - "Fourth temperature sensor should be temperature_4" + assert len(temp_sensors) == 4, ( + f"Expected exactly 4 temperature sensors, got {len(temp_sensors)}" ) - # Scenario 2: Check device-specific sensors don't conflict - device_temp_sensors = [s for s in sensors if s.name == "Device Temperature"] + # Verify each sensor is on a different device + temp_device_ids = set() + temp_object_ids = set() - # Group by device - controller_1_temps = [ - s - for s in device_temp_sensors - if getattr(s, "device_id", None) == controller_1.device_id - ] - controller_2_temps = [ - s - for s in device_temp_sensors - if getattr(s, "device_id", None) == controller_2.device_id - ] + for sensor in temp_sensors: + temp_device_ids.add(sensor.device_id) + temp_object_ids.add(sensor.object_id) - # Controller 1 should have device_temperature, device_temperature_2, device_temperature_3 - c1_object_ids = sorted([s.object_id for s in controller_1_temps]) - assert len(c1_object_ids) >= 3, ( - f"Expected at least 3 sensors on controller_1, got {len(c1_object_ids)}" - ) - assert "device_temperature" in c1_object_ids, ( - "First device sensor should not have suffix" - ) - assert "device_temperature_2" in c1_object_ids, ( - "Second device sensor should be device_temperature_2" - ) - assert "device_temperature_3" in c1_object_ids, ( - "Third device sensor should be device_temperature_3" + # All should have object_id "temperature" (no suffix) + assert sensor.object_id == "temperature", ( + f"Expected object_id 'temperature', got '{sensor.object_id}'" + ) + + # Should have 4 different device IDs (including None for main device) + assert len(temp_device_ids) == 4, ( + f"Temperature sensors should be on different devices, got {temp_device_ids}" ) - # Controller 2 should have only device_temperature (no suffix) - c2_object_ids = [s.object_id for s in controller_2_temps] - assert len(c2_object_ids) >= 1, ( - f"Expected at least 1 sensor on controller_2, got {len(c2_object_ids)}" - ) - assert "device_temperature" in c2_object_ids, ( - "Controller 2 sensor should not have suffix" + # Scenario 2: Check binary sensors "Status" on different devices + status_binary = [b for b in binary_sensors if b.name == "Status"] + assert len(status_binary) == 3, ( + f"Expected exactly 3 status binary sensors, got {len(status_binary)}" ) - # Scenario 3: Check binary sensors (different platform, same name) + # All should have object_id "status" + for binary in status_binary: + assert binary.object_id == "status", ( + f"Expected object_id 'status', got '{binary.object_id}'" + ) + + # Scenario 3: Check that sensor and binary_sensor can have same name temp_binary = [b for b in binary_sensors if b.name == "Temperature"] - binary_object_ids = sorted([b.object_id for b in temp_binary]) + assert len(temp_binary) == 1, ( + f"Expected exactly 1 temperature binary sensor, got {len(temp_binary)}" + ) + assert temp_binary[0].object_id == "temperature" - # Should have temperature, temperature_2, temperature_3 (no conflict with sensor platform) - assert len(binary_object_ids) >= 3, ( - f"Expected at least 3 binary sensors, got {len(binary_object_ids)}" - ) - assert "temperature" in binary_object_ids, ( - "First binary sensor should not have suffix" - ) - assert "temperature_2" in binary_object_ids, ( - "Second binary sensor should be temperature_2" - ) - assert "temperature_3" in binary_object_ids, ( - "Third binary sensor should be temperature_3" + # Scenario 4: Check text sensors "Device Info" on different devices + info_text = [t for t in text_sensors if t.name == "Device Info"] + assert len(info_text) == 3, ( + f"Expected exactly 3 device info text sensors, got {len(info_text)}" ) - # Scenario 4: Check text sensors with special characters - status_sensors = [t for t in text_sensors if t.name == "Status Message!"] - status_object_ids = sorted([t.object_id for t in status_sensors]) + # All should have object_id "device_info" + for text in info_text: + assert text.object_id == "device_info", ( + f"Expected object_id 'device_info', got '{text.object_id}'" + ) - # Special characters should be sanitized to _ - assert len(status_object_ids) >= 3, ( - f"Expected at least 3 status sensors, got {len(status_object_ids)}" - ) - assert "status_message_" in status_object_ids, ( - "First status sensor should be status_message_" - ) - assert "status_message__2" in status_object_ids, ( - "Second status sensor should be status_message__2" - ) - assert "status_message__3" in status_object_ids, ( - "Third status sensor should be status_message__3" + # Scenario 5: Check switches "Power" on different devices + power_switches = [s for s in switches if s.name == "Power"] + assert len(power_switches) == 3, ( + f"Expected exactly 3 power switches, got {len(power_switches)}" ) - # Scenario 5: Check switches with duplicate names - power_switches = [s for s in switches if s.name == "Power Switch"] - power_object_ids = sorted([s.object_id for s in power_switches]) + # All should have object_id "power" + for switch in power_switches: + assert switch.object_id == "power", ( + f"Expected object_id 'power', got '{switch.object_id}'" + ) - # Should have power_switch, power_switch_2 - assert len(power_object_ids) >= 2, ( - f"Expected at least 2 power switches, got {len(power_object_ids)}" + # Scenario 6: Check empty name buttons (should use device name) + empty_buttons = [b for b in buttons if b.name == ""] + assert len(empty_buttons) == 3, ( + f"Expected exactly 3 empty name buttons, got {len(empty_buttons)}" ) - assert "power_switch" in power_object_ids, ( - "First power switch should be power_switch" - ) - assert "power_switch_2" in power_object_ids, ( - "Second power switch should be power_switch_2" - ) - - # Scenario 6: Check empty names on main device (Issue #6953) - empty_binary = [b for b in binary_sensors if b.name == ""] - empty_binary_ids = sorted([b.object_id for b in empty_binary]) - - # Should use device name "duplicate-entities-test" (sanitized, not snake_case) - assert len(empty_binary_ids) >= 4, ( - f"Expected at least 4 empty name binary sensors, got {len(empty_binary_ids)}" - ) - assert "duplicate-entities-test" in empty_binary_ids, ( - "First empty binary sensor should use device name" - ) - assert "duplicate-entities-test_2" in empty_binary_ids, ( - "Second empty binary sensor should be duplicate-entities-test_2" - ) - assert "duplicate-entities-test_3" in empty_binary_ids, ( - "Third empty binary sensor should be duplicate-entities-test_3" - ) - assert "duplicate-entities-test_4" in empty_binary_ids, ( - "Fourth empty binary sensor should be duplicate-entities-test_4" - ) - - # Scenario 7: Check empty names on sub-devices (Issue #6953) - empty_switches = [s for s in switches if s.name == ""] # Group by device - c1_empty_switches = [ - s - for s in empty_switches - if getattr(s, "device_id", None) == controller_1.device_id - ] - c2_empty_switches = [ - s - for s in empty_switches - if getattr(s, "device_id", None) == controller_2.device_id - ] - main_empty_switches = [ - s - for s in empty_switches - if getattr(s, "device_id", None) - not in [controller_1.device_id, controller_2.device_id] - ] + c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id] + c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id] - # Controller 1 empty switches should use "controller_1" - c1_empty_ids = sorted([s.object_id for s in c1_empty_switches]) - assert len(c1_empty_ids) >= 3, ( - f"Expected at least 3 empty switches on controller_1, got {len(c1_empty_ids)}" + # For main device, device_id is 0 + main_buttons = [b for b in empty_buttons if b.device_id == 0] + + # Check object IDs for empty name entities + assert len(c1_buttons) == 1 and c1_buttons[0].object_id == "controller_1" + assert len(c2_buttons) == 1 and c2_buttons[0].object_id == "controller_2" + assert ( + len(main_buttons) == 1 + and main_buttons[0].object_id == "duplicate-entities-test" ) - assert "controller_1" in c1_empty_ids, "First should be controller_1" - assert "controller_1_2" in c1_empty_ids, "Second should be controller_1_2" - assert "controller_1_3" in c1_empty_ids, "Third should be controller_1_3" - # Controller 2 empty switches - c2_empty_ids = sorted([s.object_id for s in c2_empty_switches]) - assert len(c2_empty_ids) >= 2, ( - f"Expected at least 2 empty switches on controller_2, got {len(c2_empty_ids)}" + # Scenario 7: Check special characters in number names + temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] + assert len(temp_numbers) == 2, ( + f"Expected exactly 2 temperature setpoint numbers, got {len(temp_numbers)}" ) - assert "controller_2" in c2_empty_ids, "First should be controller_2" - assert "controller_2_2" in c2_empty_ids, "Second should be controller_2_2" - # Main device empty switches - main_empty_ids = sorted([s.object_id for s in main_empty_switches]) - assert len(main_empty_ids) >= 3, ( - f"Expected at least 3 empty switches on main device, got {len(main_empty_ids)}" - ) - assert "duplicate-entities-test" in main_empty_ids - assert "duplicate-entities-test_2" in main_empty_ids - assert "duplicate-entities-test_3" in main_empty_ids - - # Scenario 8: Check "xyz" duplicates (Issue #6953) - xyz_switches = [s for s in switches if s.name == "xyz"] - xyz_ids = sorted([s.object_id for s in xyz_switches]) - - assert len(xyz_ids) >= 3, ( - f"Expected at least 3 xyz switches, got {len(xyz_ids)}" - ) - assert "xyz" in xyz_ids, "First xyz switch should be xyz" - assert "xyz_2" in xyz_ids, "Second xyz switch should be xyz_2" - assert "xyz_3" in xyz_ids, "Third xyz switch should be xyz_3" + # Special characters should be sanitized to _ in object_id + for number in temp_numbers: + assert number.object_id == "temperature_setpoint_", ( + f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'" + ) # Verify we can get states for all entities (ensures they're functional) loop = asyncio.get_running_loop() states_future: asyncio.Future[None] = loop.create_future() state_count = 0 expected_count = ( - len(sensors) + len(binary_sensors) + len(text_sensors) + len(switches) + len(sensors) + + len(binary_sensors) + + len(text_sensors) + + len(switches) + + len(buttons) + + len(numbers) ) def on_state(state) -> None: diff --git a/tests/unit_tests/core/__init__.py b/tests/unit_tests/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/core/common.py b/tests/unit_tests/core/common.py new file mode 100644 index 0000000000..1848d5397b --- /dev/null +++ b/tests/unit_tests/core/common.py @@ -0,0 +1,33 @@ +"""Common test utilities for core unit tests.""" + +from collections.abc import Callable +from pathlib import Path +from unittest.mock import patch + +from esphome import config, yaml_util +from esphome.config import Config +from esphome.core import CORE + + +def load_config_from_yaml( + yaml_file: Callable[[str], str], yaml_content: str +) -> Config | None: + """Load configuration from YAML content.""" + yaml_path = yaml_file(yaml_content) + parsed_yaml = yaml_util.load_yaml(yaml_path) + + # Mock yaml_util.load_yaml to return our parsed content + with ( + patch.object(yaml_util, "load_yaml", return_value=parsed_yaml), + patch.object(CORE, "config_path", yaml_path), + ): + return config.read_config({}) + + +def load_config_from_fixture( + yaml_file: Callable[[str], str], fixture_name: str, fixtures_dir: Path +) -> Config | None: + """Load configuration from a fixture file.""" + fixture_path = fixtures_dir / fixture_name + yaml_content = fixture_path.read_text() + return load_config_from_yaml(yaml_file, yaml_content) diff --git a/tests/unit_tests/core/conftest.py b/tests/unit_tests/core/conftest.py new file mode 100644 index 0000000000..60d6738ce9 --- /dev/null +++ b/tests/unit_tests/core/conftest.py @@ -0,0 +1,18 @@ +"""Shared fixtures for core unit tests.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.fixture +def yaml_file(tmp_path: Path) -> Callable[[str], str]: + """Create a temporary YAML file for testing.""" + + def _yaml_file(content: str) -> str: + yaml_path = tmp_path / "test.yaml" + yaml_path.write_text(content) + return str(yaml_path) + + return _yaml_file diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index ba8436b7a7..46e3b513d7 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -3,55 +3,18 @@ from collections.abc import Callable from pathlib import Path from typing import Any -from unittest.mock import patch import pytest -from esphome import config, config_validation as cv, core, yaml_util -from esphome.config import Config +from esphome import config_validation as cv, core from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES -from esphome.core import CORE from esphome.core.config import Area, validate_area_config +from .common import load_config_from_fixture + FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" -@pytest.fixture -def yaml_file(tmp_path: Path) -> Callable[[str], str]: - """Create a temporary YAML file for testing.""" - - def _yaml_file(content: str) -> str: - yaml_path = tmp_path / "test.yaml" - yaml_path.write_text(content) - return str(yaml_path) - - return _yaml_file - - -def load_config_from_yaml( - yaml_file: Callable[[str], str], yaml_content: str -) -> Config | None: - """Load configuration from YAML content.""" - yaml_path = yaml_file(yaml_content) - parsed_yaml = yaml_util.load_yaml(yaml_path) - - # Mock yaml_util.load_yaml to return our parsed content - with ( - patch.object(yaml_util, "load_yaml", return_value=parsed_yaml), - patch.object(CORE, "config_path", yaml_path), - ): - return config.read_config({}) - - -def load_config_from_fixture( - yaml_file: Callable[[str], str], fixture_name: str -) -> Config | None: - """Load configuration from a fixture file.""" - fixture_path = FIXTURES_DIR / fixture_name - yaml_content = fixture_path.read_text() - return load_config_from_yaml(yaml_file, yaml_content) - - def test_validate_area_config_with_string() -> None: """Test that string area config is converted to structured format.""" result = validate_area_config("Living Room") @@ -82,7 +45,7 @@ def test_validate_area_config_with_dict() -> None: def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None: """Test that device with valid area_id works correctly.""" - result = load_config_from_fixture(yaml_file, "valid_area_device.yaml") + result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR) assert result is not None esphome_config = result["esphome"] @@ -105,7 +68,9 @@ def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None: def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: """Test multiple areas and devices configuration.""" - result = load_config_from_fixture(yaml_file, "multiple_areas_devices.yaml") + result = load_config_from_fixture( + yaml_file, "multiple_areas_devices.yaml", FIXTURES_DIR + ) assert result is not None esphome_config = result["esphome"] @@ -141,7 +106,9 @@ def test_legacy_string_area( yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture ) -> None: """Test legacy string area configuration with deprecation warning.""" - result = load_config_from_fixture(yaml_file, "legacy_string_area.yaml") + result = load_config_from_fixture( + yaml_file, "legacy_string_area.yaml", FIXTURES_DIR + ) assert result is not None esphome_config = result["esphome"] @@ -160,7 +127,7 @@ def test_area_id_collision( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that duplicate area IDs are detected.""" - result = load_config_from_fixture(yaml_file, "area_id_collision.yaml") + result = load_config_from_fixture(yaml_file, "area_id_collision.yaml", FIXTURES_DIR) assert result is None # Check for the specific error message in stdout @@ -171,7 +138,9 @@ def test_area_id_collision( def test_device_without_area(yaml_file: Callable[[str], str]) -> None: """Test that devices without area_id work correctly.""" - result = load_config_from_fixture(yaml_file, "device_without_area.yaml") + result = load_config_from_fixture( + yaml_file, "device_without_area.yaml", FIXTURES_DIR + ) assert result is not None esphome_config = result["esphome"] @@ -193,7 +162,9 @@ def test_device_with_invalid_area_id( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that device with non-existent area_id fails validation.""" - result = load_config_from_fixture(yaml_file, "device_invalid_area.yaml") + result = load_config_from_fixture( + yaml_file, "device_invalid_area.yaml", FIXTURES_DIR + ) assert result is None # Check for the specific error message in stdout @@ -208,7 +179,9 @@ def test_device_id_hash_collision( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that device IDs with hash collisions are detected.""" - result = load_config_from_fixture(yaml_file, "device_id_collision.yaml") + result = load_config_from_fixture( + yaml_file, "device_id_collision.yaml", FIXTURES_DIR + ) assert result is None # Check for the specific error message about hash collision @@ -224,7 +197,9 @@ def test_area_id_hash_collision( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that area IDs with hash collisions are detected.""" - result = load_config_from_fixture(yaml_file, "area_id_hash_collision.yaml") + result = load_config_from_fixture( + yaml_file, "area_id_hash_collision.yaml", FIXTURES_DIR + ) assert result is None # Check for the specific error message about hash collision @@ -240,7 +215,9 @@ def test_device_duplicate_id( yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] ) -> None: """Test that duplicate device IDs are detected by IDPassValidationStep.""" - result = load_config_from_fixture(yaml_file, "device_duplicate_id.yaml") + result = load_config_from_fixture( + yaml_file, "device_duplicate_id.yaml", FIXTURES_DIR + ) assert result is None # Check for the specific error message from IDPassValidationStep diff --git a/tests/unit_tests/test_entity.py b/tests/unit_tests/core/test_entity_helpers.py similarity index 78% rename from tests/unit_tests/test_entity.py rename to tests/unit_tests/core/test_entity_helpers.py index 62ce7406ff..e166eeedee 100644 --- a/tests/unit_tests/test_entity.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1,21 +1,26 @@ """Test get_base_entity_object_id function matches C++ behavior.""" -from collections.abc import Generator +from collections.abc import Callable, Generator +from pathlib import Path import re from typing import Any import pytest -from esphome import entity +from esphome.config_validation import Invalid from esphome.const import CONF_DEVICE_ID, CONF_DISABLED_BY_DEFAULT, CONF_ICON, CONF_NAME -from esphome.core import CORE, ID +from esphome.core import CORE, ID, entity_helpers +from esphome.core.entity_helpers import get_base_entity_object_id, setup_entity from esphome.cpp_generator import MockObj -from esphome.entity import get_base_entity_object_id, setup_entity from esphome.helpers import sanitize, snake_case +from .common import load_config_from_fixture + # Pre-compiled regex pattern for extracting object IDs from expressions OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" + @pytest.fixture(autouse=True) def restore_core_state() -> Generator[None, None, None]: @@ -239,7 +244,7 @@ def setup_test_environment() -> Generator[list[str], None, None]: CORE.friendly_name = "Test Device" # Store original add function - original_add = entity.add + original_add = entity_helpers.add # Track what gets added added_expressions: list[str] = [] @@ -247,11 +252,11 @@ def setup_test_environment() -> Generator[list[str], None, None]: added_expressions.append(str(expression)) return original_add(expression) - # Patch add function in entity module - entity.add = mock_add + # Patch add function in entity_helpers module + entity_helpers.add = mock_add yield added_expressions # Clean up - entity.add = original_add + entity_helpers.add = original_add def extract_object_id_from_expressions(expressions: list[str]) -> str | None: @@ -300,35 +305,6 @@ async def test_setup_entity_no_duplicates(setup_test_environment: list[str]) -> assert object_id2 == "humidity" -@pytest.mark.asyncio -async def test_setup_entity_with_duplicates(setup_test_environment: list[str]) -> None: - """Test setup_entity with duplicate names.""" - - added_expressions = setup_test_environment - - # Create mock entities - entities = [MockObj(f"sensor{i}") for i in range(4)] - - # Set up entities with same name - config = { - CONF_NAME: "Temperature", - CONF_DISABLED_BY_DEFAULT: False, - } - - object_ids: list[str] = [] - for var in entities: - added_expressions.clear() - await setup_entity(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) - - # Check that object IDs were set with proper suffixes - assert object_ids[0] == "temperature" - assert object_ids[1] == "temperature_2" - assert object_ids[2] == "temperature_3" - assert object_ids[3] == "temperature_4" - - @pytest.mark.asyncio async def test_setup_entity_different_platforms( setup_test_environment: list[str], @@ -369,17 +345,17 @@ async def test_setup_entity_different_platforms( def mock_get_variable() -> Generator[dict[ID, MockObj], None, None]: """Mock get_variable to return test devices.""" devices = {} - original_get_variable = entity.get_variable + original_get_variable = entity_helpers.get_variable async def _mock_get_variable(device_id: ID) -> MockObj: if device_id in devices: return devices[device_id] return await original_get_variable(device_id) - entity.get_variable = _mock_get_variable + entity_helpers.get_variable = _mock_get_variable yield devices # Clean up - entity.get_variable = original_get_variable + entity_helpers.get_variable = original_get_variable @pytest.mark.asyncio @@ -448,34 +424,6 @@ async def test_setup_entity_empty_name(setup_test_environment: list[str]) -> Non assert object_id == "test_device" -@pytest.mark.asyncio -async def test_setup_entity_empty_name_duplicates( - setup_test_environment: list[str], -) -> None: - """Test setup_entity with multiple empty names.""" - - added_expressions = setup_test_environment - - entities = [MockObj(f"sensor{i}") for i in range(3)] - - config = { - CONF_NAME: "", - CONF_DISABLED_BY_DEFAULT: False, - } - - object_ids: list[str] = [] - for var in entities: - added_expressions.clear() - await setup_entity(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) - - # Should use device name with suffixes - assert object_ids[0] == "test_device" - assert object_ids[1] == "test_device_2" - assert object_ids[2] == "test_device_3" - - @pytest.mark.asyncio async def test_setup_entity_special_characters( setup_test_environment: list[str], @@ -484,24 +432,18 @@ async def test_setup_entity_special_characters( added_expressions = setup_test_environment - entities = [MockObj(f"sensor{i}") for i in range(3)] + var = MockObj("sensor1") config = { CONF_NAME: "Temperature Sensor!", CONF_DISABLED_BY_DEFAULT: False, } - object_ids: list[str] = [] - for var in entities: - added_expressions.clear() - await setup_entity(var, config, "sensor") - object_id = extract_object_id_from_expressions(added_expressions) - object_ids.append(object_id) + await setup_entity(var, config, "sensor") + object_id = extract_object_id_from_expressions(added_expressions) # Special characters should be sanitized - assert object_ids[0] == "temperature_sensor_" - assert object_ids[1] == "temperature_sensor__2" - assert object_ids[2] == "temperature_sensor__3" + assert object_id == "temperature_sensor_" @pytest.mark.asyncio @@ -549,48 +491,105 @@ async def test_setup_entity_disabled_by_default( ) -@pytest.mark.asyncio -async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) -> None: - """Test complex duplicate scenario with multiple platforms and devices.""" +def test_entity_duplicate_validator() -> None: + """Test the entity_duplicate_validator function.""" + from esphome.core.entity_helpers import entity_duplicate_validator - added_expressions = setup_test_environment + # Reset CORE unique_ids for clean test + CORE.unique_ids.clear() - # Track results - results: list[tuple[str, str]] = [] + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") - # 3 sensors named "Status" - for i in range(3): - added_expressions.clear() - var = MockObj(f"sensor_status_{i}") - await setup_entity( - var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "sensor" - ) - object_id = extract_object_id_from_expressions(added_expressions) - results.append(("sensor", object_id)) + # First entity should pass + config1 = {CONF_NAME: "Temperature"} + validated1 = validator(config1) + assert validated1 == config1 + assert ("", "sensor", "temperature") in CORE.unique_ids - # 2 binary_sensors named "Status" - for i in range(2): - added_expressions.clear() - var = MockObj(f"binary_sensor_status_{i}") - await setup_entity( - var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "binary_sensor" - ) - object_id = extract_object_id_from_expressions(added_expressions) - results.append(("binary_sensor", object_id)) + # Second entity with different name should pass + config2 = {CONF_NAME: "Humidity"} + validated2 = validator(config2) + assert validated2 == config2 + assert ("", "sensor", "humidity") in CORE.unique_ids - # 1 text_sensor named "Status" - added_expressions.clear() - var = MockObj("text_sensor_status") - await setup_entity( - var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "text_sensor" + # Duplicate entity should fail + config3 = {CONF_NAME: "Temperature"} + with pytest.raises( + Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" + ): + validator(config3) + + +def test_entity_duplicate_validator_with_devices() -> None: + """Test entity_duplicate_validator with devices.""" + from esphome.core.entity_helpers import entity_duplicate_validator + + # Reset CORE unique_ids for clean test + CORE.unique_ids.clear() + + # Create validator for sensor platform + validator = entity_duplicate_validator("sensor") + + # Create mock device IDs + device1 = ID("device1", type="Device") + device2 = ID("device2", type="Device") + + # Same name on different devices should pass + config1 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} + validated1 = validator(config1) + assert validated1 == config1 + assert ("device1", "sensor", "temperature") in CORE.unique_ids + + config2 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device2} + validated2 = validator(config2) + assert validated2 == config2 + assert ("device2", "sensor", "temperature") in CORE.unique_ids + + # Duplicate on same device should fail + config3 = {CONF_NAME: "Temperature", CONF_DEVICE_ID: device1} + with pytest.raises( + Invalid, + match=r"Duplicate sensor entity with name 'Temperature' found on device 'device1'", + ): + validator(config3) + + +def test_duplicate_entity_yaml_validation( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test that duplicate entity names are caught during YAML config validation.""" + result = load_config_from_fixture(yaml_file, "duplicate_entity.yaml", FIXTURES_DIR) + assert result is None + + # Check for the duplicate entity error message + captured = capsys.readouterr() + assert "Duplicate sensor entity with name 'Temperature' found" in captured.out + + +def test_duplicate_entity_with_devices_yaml_validation( + yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] +) -> None: + """Test duplicate entity validation with devices.""" + result = load_config_from_fixture( + yaml_file, "duplicate_entity_with_devices.yaml", FIXTURES_DIR ) - object_id = extract_object_id_from_expressions(added_expressions) - results.append(("text_sensor", object_id)) + assert result is None - # Check results - each platform has its own namespace - assert results[0] == ("sensor", "status") # sensor - assert results[1] == ("sensor", "status_2") # sensor - assert results[2] == ("sensor", "status_3") # sensor - assert results[3] == ("binary_sensor", "status") # binary_sensor (new namespace) - assert results[4] == ("binary_sensor", "status_2") # binary_sensor - assert results[5] == ("text_sensor", "status") # text_sensor (new namespace) + # Check for the duplicate entity error message with device + captured = capsys.readouterr() + assert ( + "Duplicate sensor entity with name 'Temperature' found on device 'device1'" + in captured.out + ) + + +def test_entity_different_platforms_yaml_validation( + yaml_file: Callable[[str], str], +) -> None: + """Test that same entity name on different platforms is allowed.""" + result = load_config_from_fixture( + yaml_file, "entity_different_platforms.yaml", FIXTURES_DIR + ) + # This should succeed + assert result is not None diff --git a/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity.yaml b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity.yaml new file mode 100644 index 0000000000..2a8dad66c9 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity.yaml @@ -0,0 +1,13 @@ +esphome: + name: test-duplicate + +esp32: + board: esp32dev + +sensor: + - platform: template + name: "Temperature" + lambda: return 21.0; + - platform: template + name: "Temperature" # Duplicate - should fail + lambda: return 22.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml new file mode 100644 index 0000000000..42e16231a5 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/duplicate_entity_with_devices.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-duplicate-devices + devices: + - id: device1 + name: "Device 1" + - id: device2 + name: "Device 2" + +esp32: + board: esp32dev + +sensor: + # Same name on different devices - should pass + - platform: template + device_id: device1 + name: "Temperature" + lambda: return 21.0; + - platform: template + device_id: device2 + name: "Temperature" + lambda: return 22.0; + # Duplicate on same device - should fail + - platform: template + device_id: device1 + name: "Temperature" + lambda: return 23.0; diff --git a/tests/unit_tests/fixtures/core/entity_helpers/entity_different_platforms.yaml b/tests/unit_tests/fixtures/core/entity_helpers/entity_different_platforms.yaml new file mode 100644 index 0000000000..00181c52c4 --- /dev/null +++ b/tests/unit_tests/fixtures/core/entity_helpers/entity_different_platforms.yaml @@ -0,0 +1,20 @@ +esphome: + name: test-different-platforms + +esp32: + board: esp32dev + +sensor: + - platform: template + name: "Status" + lambda: return 1.0; + +binary_sensor: + - platform: template + name: "Status" # Same name, different platform - should pass + lambda: return true; + +text_sensor: + - platform: template + name: "Status" # Same name, different platform - should pass + lambda: return {"OK"};