1
0
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:
J. Nick Koston
2025-06-24 23:54:40 +02:00
40 changed files with 745 additions and 718 deletions

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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(

View File

@@ -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"]

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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),

View File

@@ -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,
*, *,

View File

@@ -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()

View File

@@ -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(
{ {

View File

@@ -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(
{ {

View File

@@ -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_() {

View File

@@ -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)

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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,

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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,
*, *,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]))

View File

@@ -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

View File

@@ -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: []

View File

@@ -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: []

View File

@@ -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:

View File

View 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)

View 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

View 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

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"};