mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	Merge remote-tracking branch 'dala318/multi_device' into integration
This commit is contained in:
		| @@ -14,8 +14,8 @@ from esphome.const import ( | |||||||
|     CONF_WEB_SERVER, |     CONF_WEB_SERVER, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@grahambrown11", "@hwstar"] | CODEOWNERS = ["@grahambrown11", "@hwstar"] | ||||||
| IS_PLATFORM_COMPONENT = True | 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( | def alarm_control_panel_schema( | ||||||
|     class_: MockObjClass, |     class_: MockObjClass, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -60,8 +60,8 @@ from esphome.const import ( | |||||||
|     DEVICE_CLASS_WINDOW, |     DEVICE_CLASS_WINDOW, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
| from esphome.util import Registry | from esphome.util import Registry | ||||||
|  |  | ||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
| @@ -491,6 +491,9 @@ _BINARY_SENSOR_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def binary_sensor_schema( | def binary_sensor_schema( | ||||||
|     class_: MockObjClass = cv.UNDEFINED, |     class_: MockObjClass = cv.UNDEFINED, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -18,8 +18,8 @@ from esphome.const import ( | |||||||
|     DEVICE_CLASS_UPDATE, |     DEVICE_CLASS_UPDATE, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
| @@ -61,6 +61,9 @@ _BUTTON_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _BUTTON_SCHEMA.add_extra(entity_duplicate_validator("button")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def button_schema( | def button_schema( | ||||||
|     class_: MockObjClass, |     class_: MockObjClass, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -48,8 +48,8 @@ from esphome.const import ( | |||||||
|     CONF_WEB_SERVER, |     CONF_WEB_SERVER, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
|  |  | ||||||
| @@ -247,6 +247,9 @@ _CLIMATE_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _CLIMATE_SCHEMA.add_extra(entity_duplicate_validator("climate")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def climate_schema( | def climate_schema( | ||||||
|     class_: MockObjClass, |     class_: MockObjClass, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -33,8 +33,8 @@ from esphome.const import ( | |||||||
|     DEVICE_CLASS_WINDOW, |     DEVICE_CLASS_WINDOW, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
|  |  | ||||||
| @@ -126,6 +126,9 @@ _COVER_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _COVER_SCHEMA.add_extra(entity_duplicate_validator("cover")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def cover_schema( | def cover_schema( | ||||||
|     class_: MockObjClass, |     class_: MockObjClass, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -22,8 +22,8 @@ from esphome.const import ( | |||||||
|     CONF_YEAR, |     CONF_YEAR, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@rfdarter", "@jesserockz"] | CODEOWNERS = ["@rfdarter", "@jesserockz"] | ||||||
|  |  | ||||||
| @@ -84,6 +84,8 @@ _DATETIME_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( | |||||||
|     .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) |     .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) | ||||||
| ).add_extra(_validate_time_present) | ).add_extra(_validate_time_present) | ||||||
|  |  | ||||||
|  | _DATETIME_SCHEMA.add_extra(entity_duplicate_validator("datetime")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def date_schema(class_: MockObjClass) -> cv.Schema: | def date_schema(class_: MockObjClass) -> cv.Schema: | ||||||
|     schema = cv.Schema( |     schema = cv.Schema( | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ from esphome.const import ( | |||||||
|     CONF_VSYNC_PIN, |     CONF_VSYNC_PIN, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE | from esphome.core import CORE | ||||||
| from esphome.cpp_helpers import setup_entity | from esphome.core.entity_helpers import setup_entity | ||||||
|  |  | ||||||
| DEPENDENCIES = ["esp32"] | DEPENDENCIES = ["esp32"] | ||||||
|  |  | ||||||
|   | |||||||
| @@ -18,8 +18,8 @@ from esphome.const import ( | |||||||
|     DEVICE_CLASS_MOTION, |     DEVICE_CLASS_MOTION, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@nohat"] | CODEOWNERS = ["@nohat"] | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
| @@ -59,6 +59,9 @@ _EVENT_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _EVENT_SCHEMA.add_extra(entity_duplicate_validator("event")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def event_schema( | def event_schema( | ||||||
|     class_: MockObjClass = cv.UNDEFINED, |     class_: MockObjClass = cv.UNDEFINED, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ from esphome.const import ( | |||||||
|     CONF_WEB_SERVER, |     CONF_WEB_SERVER, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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 | IS_PLATFORM_COMPONENT = True | ||||||
|  |  | ||||||
| @@ -161,6 +161,9 @@ _FAN_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _FAN_SCHEMA.add_extra(entity_duplicate_validator("fan")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def fan_schema( | def fan_schema( | ||||||
|     class_: cg.Pvariable, |     class_: cg.Pvariable, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -38,8 +38,8 @@ from esphome.const import ( | |||||||
|     CONF_WHITE, |     CONF_WHITE, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| from .automation import LIGHT_STATE_SCHEMA | from .automation import LIGHT_STATE_SCHEMA | ||||||
| from .effects import ( | from .effects import ( | ||||||
| @@ -110,6 +110,8 @@ LIGHT_SCHEMA = ( | |||||||
|     ) |     ) | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | LIGHT_SCHEMA.add_extra(entity_duplicate_validator("light")) | ||||||
|  |  | ||||||
| BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( | BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( | ||||||
|     { |     { | ||||||
|         cv.Optional(CONF_EFFECTS): validate_effects(BINARY_EFFECTS), |         cv.Optional(CONF_EFFECTS): validate_effects(BINARY_EFFECTS), | ||||||
|   | |||||||
| @@ -14,8 +14,8 @@ from esphome.const import ( | |||||||
|     CONF_WEB_SERVER, |     CONF_WEB_SERVER, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
| @@ -67,6 +67,9 @@ _LOCK_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _LOCK_SCHEMA.add_extra(entity_duplicate_validator("lock")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def lock_schema( | def lock_schema( | ||||||
|     class_: MockObjClass = cv.UNDEFINED, |     class_: MockObjClass = cv.UNDEFINED, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import esphome.config_validation as cv | |||||||
| from esphome.const import CONF_SIZE, CONF_TEXT | from esphome.const import CONF_SIZE, CONF_TEXT | ||||||
| from esphome.cpp_generator import MockObjClass | 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 ..lv_validation import color, color_retmapper, lv_text | ||||||
| from ..lvcode import LocalVariable, lv, lv_expr | from ..lvcode import LocalVariable, lv, lv_expr | ||||||
| from ..schemas import TEXT_SCHEMA | from ..schemas import TEXT_SCHEMA | ||||||
| @@ -34,7 +34,7 @@ class QrCodeType(WidgetType): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def get_uses(self): |     def get_uses(self): | ||||||
|         return ("canvas", "img") |         return ("canvas", "img", "label") | ||||||
|  |  | ||||||
|     def obj_creator(self, parent: MockObjClass, config: dict): |     def obj_creator(self, parent: MockObjClass, config: dict): | ||||||
|         dark_color = color_retmapper(config[CONF_DARK_COLOR]) |         dark_color = color_retmapper(config[CONF_DARK_COLOR]) | ||||||
| @@ -45,10 +45,8 @@ class QrCodeType(WidgetType): | |||||||
|     async def to_code(self, w: Widget, config): |     async def to_code(self, w: Widget, config): | ||||||
|         if (value := config.get(CONF_TEXT)) is not None: |         if (value := config.get(CONF_TEXT)) is not None: | ||||||
|             value = await lv_text.process(value) |             value = await lv_text.process(value) | ||||||
|             with LocalVariable( |             with LocalVariable("qr_text", cg.std_string, value, modifier="") as str_obj: | ||||||
|                 "qr_text", cg.const_char_ptr, value, modifier="" |                 lv.qrcode_update(w.obj, str_obj.c_str(), str_obj.size()) | ||||||
|             ) as str_obj: |  | ||||||
|                 lv.qrcode_update(w.obj, str_obj, literal(f"strlen({str_obj})")) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| qr_code_spec = QrCodeType() | qr_code_spec = QrCodeType() | ||||||
|   | |||||||
| @@ -11,9 +11,9 @@ from esphome.const import ( | |||||||
|     CONF_VOLUME, |     CONF_VOLUME, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE | 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.coroutine import coroutine_with_priority | ||||||
| from esphome.cpp_generator import MockObjClass | from esphome.cpp_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@jesserockz"] | 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( | def media_player_schema( | ||||||
|     class_: MockObjClass, |     class_: MockObjClass, | ||||||
| @@ -166,7 +168,6 @@ def media_player_schema( | |||||||
| MEDIA_PLAYER_SCHEMA = media_player_schema(MediaPlayer) | MEDIA_PLAYER_SCHEMA = media_player_schema(MediaPlayer) | ||||||
| MEDIA_PLAYER_SCHEMA.add_extra(cv.deprecated_schema_constant("media_player")) | MEDIA_PLAYER_SCHEMA.add_extra(cv.deprecated_schema_constant("media_player")) | ||||||
|  |  | ||||||
|  |  | ||||||
| MEDIA_PLAYER_ACTION_SCHEMA = automation.maybe_simple_id( | MEDIA_PLAYER_ACTION_SCHEMA = automation.maybe_simple_id( | ||||||
|     cv.Schema( |     cv.Schema( | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -68,6 +68,7 @@ def AUTO_LOAD(): | |||||||
|  |  | ||||||
| CONF_DISCOVER_IP = "discover_ip" | CONF_DISCOVER_IP = "discover_ip" | ||||||
| CONF_IDF_SEND_ASYNC = "idf_send_async" | CONF_IDF_SEND_ASYNC = "idf_send_async" | ||||||
|  | CONF_WAIT_FOR_CONNECTION = "wait_for_connection" | ||||||
|  |  | ||||||
|  |  | ||||||
| def validate_message_just_topic(value): | 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_PUBLISH_NAN_AS_NONE, default=False): cv.boolean, | ||||||
|  |             cv.Optional(CONF_WAIT_FOR_CONNECTION, default=False): cv.boolean, | ||||||
|         } |         } | ||||||
|     ), |     ), | ||||||
|     validate_config, |     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_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( | MQTT_PUBLISH_ACTION_SCHEMA = cv.Schema( | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -176,7 +176,8 @@ void MQTTClientComponent::dump_config() { | |||||||
|   } |   } | ||||||
| } | } | ||||||
| bool MQTTClientComponent::can_proceed() { | 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_() { | void MQTTClientComponent::start_dnslookup_() { | ||||||
|   | |||||||
| @@ -4,11 +4,11 @@ | |||||||
|  |  | ||||||
| #ifdef USE_MQTT | #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/json/json_util.h" | ||||||
| #include "esphome/components/network/ip_address.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) | #if defined(USE_ESP32) | ||||||
| #include "mqtt_backend_esp32.h" | #include "mqtt_backend_esp32.h" | ||||||
| #elif defined(USE_ESP8266) | #elif defined(USE_ESP8266) | ||||||
| @@ -267,6 +267,8 @@ class MQTTClientComponent : public Component { | |||||||
|   void set_publish_nan_as_none(bool publish_nan_as_none); |   void set_publish_nan_as_none(bool publish_nan_as_none); | ||||||
|   bool is_publish_nan_as_none() const; |   bool is_publish_nan_as_none() const; | ||||||
|  |  | ||||||
|  |   void set_wait_for_connection(bool wait_for_connection) { this->wait_for_connection_ = wait_for_connection; } | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   void send_device_info_(); |   void send_device_info_(); | ||||||
|  |  | ||||||
| @@ -334,6 +336,7 @@ class MQTTClientComponent : public Component { | |||||||
|   optional<MQTTClientDisconnectReason> disconnect_reason_{}; |   optional<MQTTClientDisconnectReason> disconnect_reason_{}; | ||||||
|  |  | ||||||
|   bool publish_nan_as_none_{false}; |   bool publish_nan_as_none_{false}; | ||||||
|  |   bool wait_for_connection_{false}; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| extern MQTTClientComponent *global_mqtt_client;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) | extern MQTTClientComponent *global_mqtt_client;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) | ||||||
|   | |||||||
| @@ -76,8 +76,8 @@ from esphome.const import ( | |||||||
|     DEVICE_CLASS_WIND_SPEED, |     DEVICE_CLASS_WIND_SPEED, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
| DEVICE_CLASSES = [ | DEVICE_CLASSES = [ | ||||||
| @@ -207,6 +207,9 @@ _NUMBER_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _NUMBER_SCHEMA.add_extra(entity_duplicate_validator("number")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def number_schema( | def number_schema( | ||||||
|     class_: MockObjClass, |     class_: MockObjClass, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -17,8 +17,8 @@ from esphome.const import ( | |||||||
|     CONF_WEB_SERVER, |     CONF_WEB_SERVER, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
| @@ -65,6 +65,9 @@ _SELECT_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _SELECT_SCHEMA.add_extra(entity_duplicate_validator("select")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def select_schema( | def select_schema( | ||||||
|     class_: MockObjClass, |     class_: MockObjClass, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -101,8 +101,8 @@ from esphome.const import ( | |||||||
|     ENTITY_CATEGORY_CONFIG, |     ENTITY_CATEGORY_CONFIG, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
| from esphome.util import Registry | from esphome.util import Registry | ||||||
|  |  | ||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
| @@ -318,6 +318,8 @@ _SENSOR_SCHEMA = ( | |||||||
|     ) |     ) | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | _SENSOR_SCHEMA.add_extra(entity_duplicate_validator("sensor")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def sensor_schema( | def sensor_schema( | ||||||
|     class_: MockObjClass = cv.UNDEFINED, |     class_: MockObjClass = cv.UNDEFINED, | ||||||
|   | |||||||
| @@ -20,8 +20,8 @@ from esphome.const import ( | |||||||
|     DEVICE_CLASS_SWITCH, |     DEVICE_CLASS_SWITCH, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
| @@ -91,6 +91,9 @@ _SWITCH_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def switch_schema( | def switch_schema( | ||||||
|     class_: MockObjClass, |     class_: MockObjClass, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -14,8 +14,8 @@ from esphome.const import ( | |||||||
|     CONF_WEB_SERVER, |     CONF_WEB_SERVER, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@mauritskorse"] | CODEOWNERS = ["@mauritskorse"] | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
| @@ -58,6 +58,9 @@ _TEXT_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _TEXT_SCHEMA.add_extra(entity_duplicate_validator("text")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def text_schema( | def text_schema( | ||||||
|     class_: MockObjClass = cv.UNDEFINED, |     class_: MockObjClass = cv.UNDEFINED, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -21,8 +21,8 @@ from esphome.const import ( | |||||||
|     DEVICE_CLASS_TIMESTAMP, |     DEVICE_CLASS_TIMESTAMP, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
| from esphome.util import Registry | from esphome.util import Registry | ||||||
|  |  | ||||||
| DEVICE_CLASSES = [ | DEVICE_CLASSES = [ | ||||||
| @@ -153,6 +153,9 @@ _TEXT_SENSOR_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _TEXT_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("text_sensor")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def text_sensor_schema( | def text_sensor_schema( | ||||||
|     class_: MockObjClass = cv.UNDEFINED, |     class_: MockObjClass = cv.UNDEFINED, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -15,8 +15,8 @@ from esphome.const import ( | |||||||
|     ENTITY_CATEGORY_CONFIG, |     ENTITY_CATEGORY_CONFIG, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| CODEOWNERS = ["@jesserockz"] | CODEOWNERS = ["@jesserockz"] | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
| @@ -58,6 +58,9 @@ _UPDATE_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _UPDATE_SCHEMA.add_extra(entity_duplicate_validator("update")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def update_schema( | def update_schema( | ||||||
|     class_: MockObjClass = cv.UNDEFINED, |     class_: MockObjClass = cv.UNDEFINED, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -22,8 +22,8 @@ from esphome.const import ( | |||||||
|     DEVICE_CLASS_WATER, |     DEVICE_CLASS_WATER, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | 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_generator import MockObjClass | ||||||
| from esphome.cpp_helpers import setup_entity |  | ||||||
|  |  | ||||||
| IS_PLATFORM_COMPONENT = True | IS_PLATFORM_COMPONENT = True | ||||||
|  |  | ||||||
| @@ -103,6 +103,9 @@ _VALVE_SCHEMA = ( | |||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | _VALVE_SCHEMA.add_extra(entity_duplicate_validator("valve")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def valve_schema( | def valve_schema( | ||||||
|     class_: MockObjClass = cv.UNDEFINED, |     class_: MockObjClass = cv.UNDEFINED, | ||||||
|     *, |     *, | ||||||
|   | |||||||
| @@ -523,8 +523,8 @@ class EsphomeCore: | |||||||
|         # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count |         # Key: platform name (e.g. "sensor", "binary_sensor"), Value: count | ||||||
|         self.platform_counts: defaultdict[str, int] = defaultdict(int) |         self.platform_counts: defaultdict[str, int] = defaultdict(int) | ||||||
|         # Track entity unique IDs to handle duplicates |         # Track entity unique IDs to handle duplicates | ||||||
|         # Key: (device_id, platform, object_id), Value: count of duplicates |         # Set of (device_id, platform, sanitized_name) tuples | ||||||
|         self.unique_ids: dict[tuple[int, str, str], int] = {} |         self.unique_ids: set[tuple[str, str, str]] = set() | ||||||
|         # Whether ESPHome was started in verbose mode |         # Whether ESPHome was started in verbose mode | ||||||
|         self.verbose = False |         self.verbose = False | ||||||
|         # Whether ESPHome was started in quiet mode |         # Whether ESPHome was started in quiet mode | ||||||
| @@ -556,7 +556,7 @@ class EsphomeCore: | |||||||
|         self.loaded_integrations = set() |         self.loaded_integrations = set() | ||||||
|         self.component_ids = set() |         self.component_ids = set() | ||||||
|         self.platform_counts = defaultdict(int) |         self.platform_counts = defaultdict(int) | ||||||
|         self.unique_ids = {} |         self.unique_ids = set() | ||||||
|         PIN_SCHEMA_REGISTRY.reset() |         PIN_SCHEMA_REGISTRY.reset() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|   | |||||||
| @@ -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 | 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): | 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 config | ||||||
|  |  | ||||||
|     return inherit_property |     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 | ||||||
|   | |||||||
| @@ -11,9 +11,6 @@ from esphome.core import CORE, ID, coroutine | |||||||
| from esphome.coroutine import FakeAwaitable | from esphome.coroutine import FakeAwaitable | ||||||
| from esphome.cpp_generator import add, get_variable | from esphome.cpp_generator import add, get_variable | ||||||
| from esphome.cpp_types import App | 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.types import ConfigFragmentType, ConfigType | ||||||
| from esphome.util import Registry, RegistryEntry | from esphome.util import Registry, RegistryEntry | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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])) |  | ||||||
| @@ -646,7 +646,9 @@ lvgl: | |||||||
|             on_click: |             on_click: | ||||||
|               lvgl.qrcode.update: |               lvgl.qrcode.update: | ||||||
|                 id: lv_qr |                 id: lv_qr | ||||||
|                 text: homeassistant.io |                 text: | ||||||
|  |                   format: "A string with a number %d" | ||||||
|  |                   args: ['(int)(random_uint32() % 1000)'] | ||||||
|  |  | ||||||
|         - slider: |         - slider: | ||||||
|             min_value: 0 |             min_value: 0 | ||||||
|   | |||||||
| @@ -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: [] |  | ||||||
| @@ -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: [] | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| """Integration test for duplicate entity handling.""" | """Integration test for duplicate entity handling with new validation.""" | ||||||
|  |  | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| @@ -11,12 +11,12 @@ from .types import APIClientConnectedFactory, RunCompiledFunction | |||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_duplicate_entities( | async def test_duplicate_entities_on_different_devices( | ||||||
|     yaml_config: str, |     yaml_config: str, | ||||||
|     run_compiled: RunCompiledFunction, |     run_compiled: RunCompiledFunction, | ||||||
|     api_client_connected: APIClientConnectedFactory, |     api_client_connected: APIClientConnectedFactory, | ||||||
| ) -> None: | ) -> 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: |     async with run_compiled(yaml_config), api_client_connected() as client: | ||||||
|         # Get device info |         # Get device info | ||||||
|         device_info = await client.device_info() |         device_info = await client.device_info() | ||||||
| @@ -24,14 +24,16 @@ async def test_duplicate_entities( | |||||||
|  |  | ||||||
|         # Get devices |         # Get devices | ||||||
|         devices = device_info.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 |         # Find our test devices | ||||||
|         controller_1 = next((d for d in devices if d.name == "Controller 1"), None) |         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_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_1 is not None, "Controller 1 device not found" | ||||||
|         assert controller_2 is not None, "Controller 2 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 |         # Get entity list | ||||||
|         entities = await client.list_entities_services() |         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" |             e for e in all_entities if e.__class__.__name__ == "TextSensorInfo" | ||||||
|         ] |         ] | ||||||
|         switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"] |         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_sensors = [s for s in sensors if s.name == "Temperature"] | ||||||
|         temp_object_ids = sorted([s.object_id for s in temp_sensors]) |         assert len(temp_sensors) == 4, ( | ||||||
|  |             f"Expected exactly 4 temperature sensors, got {len(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" |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Scenario 2: Check device-specific sensors don't conflict |         # Verify each sensor is on a different device | ||||||
|         device_temp_sensors = [s for s in sensors if s.name == "Device Temperature"] |         temp_device_ids = set() | ||||||
|  |         temp_object_ids = set() | ||||||
|  |  | ||||||
|         # Group by device |         for sensor in temp_sensors: | ||||||
|         controller_1_temps = [ |             temp_device_ids.add(sensor.device_id) | ||||||
|             s |             temp_object_ids.add(sensor.object_id) | ||||||
|             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 |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|         # Controller 1 should have device_temperature, device_temperature_2, device_temperature_3 |             # All should have object_id "temperature" (no suffix) | ||||||
|         c1_object_ids = sorted([s.object_id for s in controller_1_temps]) |             assert sensor.object_id == "temperature", ( | ||||||
|         assert len(c1_object_ids) >= 3, ( |                 f"Expected object_id 'temperature', got '{sensor.object_id}'" | ||||||
|             f"Expected at least 3 sensors on controller_1, got {len(c1_object_ids)}" |             ) | ||||||
|         ) |  | ||||||
|         assert "device_temperature" in c1_object_ids, ( |         # Should have 4 different device IDs (including None for main device) | ||||||
|             "First device sensor should not have suffix" |         assert len(temp_device_ids) == 4, ( | ||||||
|         ) |             f"Temperature sensors should be on different devices, got {temp_device_ids}" | ||||||
|         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" |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Controller 2 should have only device_temperature (no suffix) |         # Scenario 2: Check binary sensors "Status" on different devices | ||||||
|         c2_object_ids = [s.object_id for s in controller_2_temps] |         status_binary = [b for b in binary_sensors if b.name == "Status"] | ||||||
|         assert len(c2_object_ids) >= 1, ( |         assert len(status_binary) == 3, ( | ||||||
|             f"Expected at least 1 sensor on controller_2, got {len(c2_object_ids)}" |             f"Expected exactly 3 status binary sensors, got {len(status_binary)}" | ||||||
|         ) |  | ||||||
|         assert "device_temperature" in c2_object_ids, ( |  | ||||||
|             "Controller 2 sensor should not have suffix" |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # 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"] |         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) |         # Scenario 4: Check text sensors "Device Info" on different devices | ||||||
|         assert len(binary_object_ids) >= 3, ( |         info_text = [t for t in text_sensors if t.name == "Device Info"] | ||||||
|             f"Expected at least 3 binary sensors, got {len(binary_object_ids)}" |         assert len(info_text) == 3, ( | ||||||
|         ) |             f"Expected exactly 3 device info text sensors, got {len(info_text)}" | ||||||
|         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 with special characters |         # All should have object_id "device_info" | ||||||
|         status_sensors = [t for t in text_sensors if t.name == "Status Message!"] |         for text in info_text: | ||||||
|         status_object_ids = sorted([t.object_id for t in status_sensors]) |             assert text.object_id == "device_info", ( | ||||||
|  |                 f"Expected object_id 'device_info', got '{text.object_id}'" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|         # Special characters should be sanitized to _ |         # Scenario 5: Check switches "Power" on different devices | ||||||
|         assert len(status_object_ids) >= 3, ( |         power_switches = [s for s in switches if s.name == "Power"] | ||||||
|             f"Expected at least 3 status sensors, got {len(status_object_ids)}" |         assert len(power_switches) == 3, ( | ||||||
|         ) |             f"Expected exactly 3 power switches, got {len(power_switches)}" | ||||||
|         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 with duplicate names |         # All should have object_id "power" | ||||||
|         power_switches = [s for s in switches if s.name == "Power Switch"] |         for switch in power_switches: | ||||||
|         power_object_ids = sorted([s.object_id for s in power_switches]) |             assert switch.object_id == "power", ( | ||||||
|  |                 f"Expected object_id 'power', got '{switch.object_id}'" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|         # Should have power_switch, power_switch_2 |         # Scenario 6: Check empty name buttons (should use device name) | ||||||
|         assert len(power_object_ids) >= 2, ( |         empty_buttons = [b for b in buttons if b.name == ""] | ||||||
|             f"Expected at least 2 power switches, got {len(power_object_ids)}" |         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 |         # Group by device | ||||||
|         c1_empty_switches = [ |         c1_buttons = [b for b in empty_buttons if b.device_id == controller_1.device_id] | ||||||
|             s |         c2_buttons = [b for b in empty_buttons if b.device_id == controller_2.device_id] | ||||||
|             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] |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|         # Controller 1 empty switches should use "controller_1" |         # For main device, device_id is 0 | ||||||
|         c1_empty_ids = sorted([s.object_id for s in c1_empty_switches]) |         main_buttons = [b for b in empty_buttons if b.device_id == 0] | ||||||
|         assert len(c1_empty_ids) >= 3, ( |  | ||||||
|             f"Expected at least 3 empty switches on controller_1, got {len(c1_empty_ids)}" |         # 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 |         # Scenario 7: Check special characters in number names | ||||||
|         c2_empty_ids = sorted([s.object_id for s in c2_empty_switches]) |         temp_numbers = [n for n in numbers if n.name == "Temperature Setpoint!"] | ||||||
|         assert len(c2_empty_ids) >= 2, ( |         assert len(temp_numbers) == 2, ( | ||||||
|             f"Expected at least 2 empty switches on controller_2, got {len(c2_empty_ids)}" |             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 |         # Special characters should be sanitized to _ in object_id | ||||||
|         main_empty_ids = sorted([s.object_id for s in main_empty_switches]) |         for number in temp_numbers: | ||||||
|         assert len(main_empty_ids) >= 3, ( |             assert number.object_id == "temperature_setpoint_", ( | ||||||
|             f"Expected at least 3 empty switches on main device, got {len(main_empty_ids)}" |                 f"Expected object_id 'temperature_setpoint_', got '{number.object_id}'" | ||||||
|         ) |             ) | ||||||
|         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" |  | ||||||
|  |  | ||||||
|         # Verify we can get states for all entities (ensures they're functional) |         # Verify we can get states for all entities (ensures they're functional) | ||||||
|         loop = asyncio.get_running_loop() |         loop = asyncio.get_running_loop() | ||||||
|         states_future: asyncio.Future[None] = loop.create_future() |         states_future: asyncio.Future[None] = loop.create_future() | ||||||
|         state_count = 0 |         state_count = 0 | ||||||
|         expected_count = ( |         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: |         def on_state(state) -> None: | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								tests/unit_tests/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/unit_tests/core/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										33
									
								
								tests/unit_tests/core/common.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								tests/unit_tests/core/common.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
							
								
								
									
										18
									
								
								tests/unit_tests/core/conftest.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								tests/unit_tests/core/conftest.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -3,55 +3,18 @@ | |||||||
| from collections.abc import Callable | from collections.abc import Callable | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from typing import Any | from typing import Any | ||||||
| from unittest.mock import patch |  | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from esphome import config, config_validation as cv, core, yaml_util | from esphome import config_validation as cv, core | ||||||
| from esphome.config import Config |  | ||||||
| from esphome.const import CONF_AREA, CONF_AREAS, CONF_DEVICES | 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 esphome.core.config import Area, validate_area_config | ||||||
|  |  | ||||||
|  | from .common import load_config_from_fixture | ||||||
|  |  | ||||||
| FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" | 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: | def test_validate_area_config_with_string() -> None: | ||||||
|     """Test that string area config is converted to structured format.""" |     """Test that string area config is converted to structured format.""" | ||||||
|     result = validate_area_config("Living Room") |     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: | def test_device_with_valid_area_id(yaml_file: Callable[[str], str]) -> None: | ||||||
|     """Test that device with valid area_id works correctly.""" |     """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 |     assert result is not None | ||||||
|  |  | ||||||
|     esphome_config = result["esphome"] |     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: | def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None: | ||||||
|     """Test multiple areas and devices configuration.""" |     """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 |     assert result is not None | ||||||
|  |  | ||||||
|     esphome_config = result["esphome"] |     esphome_config = result["esphome"] | ||||||
| @@ -141,7 +106,9 @@ def test_legacy_string_area( | |||||||
|     yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture |     yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test legacy string area configuration with deprecation warning.""" |     """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 |     assert result is not None | ||||||
|  |  | ||||||
|     esphome_config = result["esphome"] |     esphome_config = result["esphome"] | ||||||
| @@ -160,7 +127,7 @@ def test_area_id_collision( | |||||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] |     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test that duplicate area IDs are detected.""" |     """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 |     assert result is None | ||||||
|  |  | ||||||
|     # Check for the specific error message in stdout |     # 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: | def test_device_without_area(yaml_file: Callable[[str], str]) -> None: | ||||||
|     """Test that devices without area_id work correctly.""" |     """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 |     assert result is not None | ||||||
|  |  | ||||||
|     esphome_config = result["esphome"] |     esphome_config = result["esphome"] | ||||||
| @@ -193,7 +162,9 @@ def test_device_with_invalid_area_id( | |||||||
|     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] |     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test that device with non-existent area_id fails validation.""" |     """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 |     assert result is None | ||||||
|  |  | ||||||
|     # Check for the specific error message in stdout |     # 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] |     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test that device IDs with hash collisions are detected.""" |     """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 |     assert result is None | ||||||
|  |  | ||||||
|     # Check for the specific error message about hash collision |     # 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] |     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test that area IDs with hash collisions are detected.""" |     """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 |     assert result is None | ||||||
|  |  | ||||||
|     # Check for the specific error message about hash collision |     # 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] |     yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str] | ||||||
| ) -> None: | ) -> None: | ||||||
|     """Test that duplicate device IDs are detected by IDPassValidationStep.""" |     """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 |     assert result is None | ||||||
|  |  | ||||||
|     # Check for the specific error message from IDPassValidationStep |     # Check for the specific error message from IDPassValidationStep | ||||||
|   | |||||||
| @@ -1,21 +1,26 @@ | |||||||
| """Test get_base_entity_object_id function matches C++ behavior.""" | """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 | import re | ||||||
| from typing import Any | from typing import Any | ||||||
| 
 | 
 | ||||||
| import pytest | 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.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.cpp_generator import MockObj | ||||||
| from esphome.entity import get_base_entity_object_id, setup_entity |  | ||||||
| from esphome.helpers import sanitize, snake_case | from esphome.helpers import sanitize, snake_case | ||||||
| 
 | 
 | ||||||
|  | from .common import load_config_from_fixture | ||||||
|  | 
 | ||||||
| # Pre-compiled regex pattern for extracting object IDs from expressions | # Pre-compiled regex pattern for extracting object IDs from expressions | ||||||
| OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') | OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') | ||||||
| 
 | 
 | ||||||
|  | FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.fixture(autouse=True) | @pytest.fixture(autouse=True) | ||||||
| def restore_core_state() -> Generator[None, None, None]: | 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" |     CORE.friendly_name = "Test Device" | ||||||
|     # Store original add function |     # Store original add function | ||||||
| 
 | 
 | ||||||
|     original_add = entity.add |     original_add = entity_helpers.add | ||||||
|     # Track what gets added |     # Track what gets added | ||||||
|     added_expressions: list[str] = [] |     added_expressions: list[str] = [] | ||||||
| 
 | 
 | ||||||
| @@ -247,11 +252,11 @@ def setup_test_environment() -> Generator[list[str], None, None]: | |||||||
|         added_expressions.append(str(expression)) |         added_expressions.append(str(expression)) | ||||||
|         return original_add(expression) |         return original_add(expression) | ||||||
| 
 | 
 | ||||||
|     # Patch add function in entity module |     # Patch add function in entity_helpers module | ||||||
|     entity.add = mock_add |     entity_helpers.add = mock_add | ||||||
|     yield added_expressions |     yield added_expressions | ||||||
|     # Clean up |     # Clean up | ||||||
|     entity.add = original_add |     entity_helpers.add = original_add | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def extract_object_id_from_expressions(expressions: list[str]) -> str | None: | 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" |     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 | @pytest.mark.asyncio | ||||||
| async def test_setup_entity_different_platforms( | async def test_setup_entity_different_platforms( | ||||||
|     setup_test_environment: list[str], |     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]: | def mock_get_variable() -> Generator[dict[ID, MockObj], None, None]: | ||||||
|     """Mock get_variable to return test devices.""" |     """Mock get_variable to return test devices.""" | ||||||
|     devices = {} |     devices = {} | ||||||
|     original_get_variable = entity.get_variable |     original_get_variable = entity_helpers.get_variable | ||||||
| 
 | 
 | ||||||
|     async def _mock_get_variable(device_id: ID) -> MockObj: |     async def _mock_get_variable(device_id: ID) -> MockObj: | ||||||
|         if device_id in devices: |         if device_id in devices: | ||||||
|             return devices[device_id] |             return devices[device_id] | ||||||
|         return await original_get_variable(device_id) |         return await original_get_variable(device_id) | ||||||
| 
 | 
 | ||||||
|     entity.get_variable = _mock_get_variable |     entity_helpers.get_variable = _mock_get_variable | ||||||
|     yield devices |     yield devices | ||||||
|     # Clean up |     # Clean up | ||||||
|     entity.get_variable = original_get_variable |     entity_helpers.get_variable = original_get_variable | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.asyncio | @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" |     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 | @pytest.mark.asyncio | ||||||
| async def test_setup_entity_special_characters( | async def test_setup_entity_special_characters( | ||||||
|     setup_test_environment: list[str], |     setup_test_environment: list[str], | ||||||
| @@ -484,24 +432,18 @@ async def test_setup_entity_special_characters( | |||||||
| 
 | 
 | ||||||
|     added_expressions = setup_test_environment |     added_expressions = setup_test_environment | ||||||
| 
 | 
 | ||||||
|     entities = [MockObj(f"sensor{i}") for i in range(3)] |     var = MockObj("sensor1") | ||||||
| 
 | 
 | ||||||
|     config = { |     config = { | ||||||
|         CONF_NAME: "Temperature Sensor!", |         CONF_NAME: "Temperature Sensor!", | ||||||
|         CONF_DISABLED_BY_DEFAULT: False, |         CONF_DISABLED_BY_DEFAULT: False, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     object_ids: list[str] = [] |     await setup_entity(var, config, "sensor") | ||||||
|     for var in entities: |     object_id = extract_object_id_from_expressions(added_expressions) | ||||||
|         added_expressions.clear() |  | ||||||
|         await setup_entity(var, config, "sensor") |  | ||||||
|         object_id = extract_object_id_from_expressions(added_expressions) |  | ||||||
|         object_ids.append(object_id) |  | ||||||
| 
 | 
 | ||||||
|     # Special characters should be sanitized |     # Special characters should be sanitized | ||||||
|     assert object_ids[0] == "temperature_sensor_" |     assert object_id == "temperature_sensor_" | ||||||
|     assert object_ids[1] == "temperature_sensor__2" |  | ||||||
|     assert object_ids[2] == "temperature_sensor__3" |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| @@ -549,48 +491,105 @@ async def test_setup_entity_disabled_by_default( | |||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @pytest.mark.asyncio | def test_entity_duplicate_validator() -> None: | ||||||
| async def test_setup_entity_mixed_duplicates(setup_test_environment: list[str]) -> None: |     """Test the entity_duplicate_validator function.""" | ||||||
|     """Test complex duplicate scenario with multiple platforms and devices.""" |     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 |     # Create validator for sensor platform | ||||||
|     results: list[tuple[str, str]] = [] |     validator = entity_duplicate_validator("sensor") | ||||||
| 
 | 
 | ||||||
|     # 3 sensors named "Status" |     # First entity should pass | ||||||
|     for i in range(3): |     config1 = {CONF_NAME: "Temperature"} | ||||||
|         added_expressions.clear() |     validated1 = validator(config1) | ||||||
|         var = MockObj(f"sensor_status_{i}") |     assert validated1 == config1 | ||||||
|         await setup_entity( |     assert ("", "sensor", "temperature") in CORE.unique_ids | ||||||
|             var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "sensor" |  | ||||||
|         ) |  | ||||||
|         object_id = extract_object_id_from_expressions(added_expressions) |  | ||||||
|         results.append(("sensor", object_id)) |  | ||||||
| 
 | 
 | ||||||
|     # 2 binary_sensors named "Status" |     # Second entity with different name should pass | ||||||
|     for i in range(2): |     config2 = {CONF_NAME: "Humidity"} | ||||||
|         added_expressions.clear() |     validated2 = validator(config2) | ||||||
|         var = MockObj(f"binary_sensor_status_{i}") |     assert validated2 == config2 | ||||||
|         await setup_entity( |     assert ("", "sensor", "humidity") in CORE.unique_ids | ||||||
|             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)) |  | ||||||
| 
 | 
 | ||||||
|     # 1 text_sensor named "Status" |     # Duplicate entity should fail | ||||||
|     added_expressions.clear() |     config3 = {CONF_NAME: "Temperature"} | ||||||
|     var = MockObj("text_sensor_status") |     with pytest.raises( | ||||||
|     await setup_entity( |         Invalid, match=r"Duplicate sensor entity with name 'Temperature' found" | ||||||
|         var, {CONF_NAME: "Status", CONF_DISABLED_BY_DEFAULT: False}, "text_sensor" |     ): | ||||||
|  |         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) |     assert result is None | ||||||
|     results.append(("text_sensor", object_id)) |  | ||||||
| 
 | 
 | ||||||
|     # Check results - each platform has its own namespace |     # Check for the duplicate entity error message with device | ||||||
|     assert results[0] == ("sensor", "status")  # sensor |     captured = capsys.readouterr() | ||||||
|     assert results[1] == ("sensor", "status_2")  # sensor |     assert ( | ||||||
|     assert results[2] == ("sensor", "status_3")  # sensor |         "Duplicate sensor entity with name 'Temperature' found on device 'device1'" | ||||||
|     assert results[3] == ("binary_sensor", "status")  # binary_sensor (new namespace) |         in captured.out | ||||||
|     assert results[4] == ("binary_sensor", "status_2")  # binary_sensor |     ) | ||||||
|     assert results[5] == ("text_sensor", "status")  # text_sensor (new namespace) | 
 | ||||||
|  | 
 | ||||||
|  | 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 | ||||||
| @@ -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; | ||||||
| @@ -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; | ||||||
| @@ -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"}; | ||||||
		Reference in New Issue
	
	Block a user