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