fix conflicts

This commit is contained in:
J. Nick Koston
2025-06-24 16:13:34 +02:00
parent d4e978369a
commit e370872ec1
25 changed files with 219 additions and 36 deletions

View File

@@ -190,7 +190,7 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id(
async def setup_alarm_control_panel_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "alarm_control_panel")
for conf in config.get(CONF_ON_STATE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)

View File

@@ -521,7 +521,7 @@ BINARY_SENSOR_SCHEMA.add_extra(cv.deprecated_schema_constant("binary_sensor"))
async def setup_binary_sensor_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "binary_sensor")
if (device_class := config.get(CONF_DEVICE_CLASS)) is not None:
cg.add(var.set_device_class(device_class))

View File

@@ -87,7 +87,7 @@ BUTTON_SCHEMA.add_extra(cv.deprecated_schema_constant("button"))
async def setup_button_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "button")
for conf in config.get(CONF_ON_PRESS, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)

View File

@@ -273,7 +273,7 @@ CLIMATE_SCHEMA.add_extra(cv.deprecated_schema_constant("climate"))
async def setup_climate_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "climate")
visual = config[CONF_VISUAL]
if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None:

View File

@@ -154,7 +154,7 @@ COVER_SCHEMA.add_extra(cv.deprecated_schema_constant("cover"))
async def setup_cover_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "cover")
if (device_class := config.get(CONF_DEVICE_CLASS)) is not None:
cg.add(var.set_device_class(device_class))

View File

@@ -133,7 +133,7 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema:
async def setup_datetime_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "datetime")
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:
mqtt_ = cg.new_Pvariable(mqtt_id, var)

View File

@@ -284,7 +284,7 @@ SETTERS = {
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await setup_entity(var, config)
await setup_entity(var, config, "camera")
await cg.register_component(var, config)
for key, setter in SETTERS.items():

View File

@@ -88,7 +88,7 @@ EVENT_SCHEMA.add_extra(cv.deprecated_schema_constant("event"))
async def setup_event_core_(var, config, *, event_types: list[str]):
await setup_entity(var, config)
await setup_entity(var, config, "event")
for conf in config.get(CONF_ON_EVENT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)

View File

@@ -225,7 +225,7 @@ def validate_preset_modes(value):
async def setup_fan_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "fan")
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))

View File

@@ -207,7 +207,7 @@ def validate_color_temperature_channels(value):
async def setup_light_core_(light_var, output_var, config):
await setup_entity(light_var, config)
await setup_entity(light_var, config, "light")
cg.add(light_var.set_restore_mode(config[CONF_RESTORE_MODE]))

View File

@@ -94,7 +94,7 @@ LOCK_SCHEMA.add_extra(cv.deprecated_schema_constant("lock"))
async def _setup_lock_core(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "lock")
for conf in config.get(CONF_ON_LOCK, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)

View File

@@ -81,7 +81,7 @@ IsAnnouncingCondition = media_player_ns.class_(
async def setup_media_player_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "media_player")
for conf in config.get(CONF_ON_STATE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)

View File

@@ -237,7 +237,7 @@ NUMBER_SCHEMA.add_extra(cv.deprecated_schema_constant("number"))
async def setup_number_core_(
var, config, *, min_value: float, max_value: float, step: float
):
await setup_entity(var, config)
await setup_entity(var, config, "number")
cg.add(var.traits.set_min_value(min_value))
cg.add(var.traits.set_max_value(max_value))

View File

@@ -89,7 +89,7 @@ SELECT_SCHEMA.add_extra(cv.deprecated_schema_constant("select"))
async def setup_select_core_(var, config, *, options: list[str]):
await setup_entity(var, config)
await setup_entity(var, config, "select")
cg.add(var.traits.set_options(options))

View File

@@ -787,7 +787,7 @@ async def build_filters(config):
async def setup_sensor_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "sensor")
if (device_class := config.get(CONF_DEVICE_CLASS)) is not None:
cg.add(var.set_device_class(device_class))

View File

@@ -131,7 +131,7 @@ SWITCH_SCHEMA.add_extra(cv.deprecated_schema_constant("switch"))
async def setup_switch_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "switch")
if (inverted := config.get(CONF_INVERTED)) is not None:
cg.add(var.set_inverted(inverted))

View File

@@ -94,7 +94,7 @@ async def setup_text_core_(
max_length: int | None,
pattern: str | None,
):
await setup_entity(var, config)
await setup_entity(var, config, "text")
cg.add(var.traits.set_min_length(min_length))
cg.add(var.traits.set_max_length(max_length))

View File

@@ -186,7 +186,7 @@ async def build_filters(config):
async def setup_text_sensor_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "text_sensor")
if (device_class := config.get(CONF_DEVICE_CLASS)) is not None:
cg.add(var.set_device_class(device_class))

View File

@@ -87,7 +87,7 @@ UPDATE_SCHEMA.add_extra(cv.deprecated_schema_constant("update"))
async def setup_update_core_(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "update")
if device_class_config := config.get(CONF_DEVICE_CLASS):
cg.add(var.set_device_class(device_class_config))

View File

@@ -132,7 +132,7 @@ VALVE_SCHEMA.add_extra(cv.deprecated_schema_constant("valve"))
async def _setup_valve_core(var, config):
await setup_entity(var, config)
await setup_entity(var, config, "valve")
if device_class_config := config.get(CONF_DEVICE_CLASS):
cg.add(var.set_device_class(device_class_config))

View File

@@ -522,6 +522,9 @@ class EsphomeCore:
# Dict to track platform entity counts for pre-allocation
# 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] = {}
# Whether ESPHome was started in verbose mode
self.verbose = False
# Whether ESPHome was started in quiet mode
@@ -553,6 +556,7 @@ class EsphomeCore:
self.loaded_integrations = set()
self.component_ids = set()
self.platform_counts = defaultdict(int)
self.unique_ids = {}
PIN_SCHEMA_REGISTRY.reset()
@property

View File

@@ -36,21 +36,15 @@ void EntityBase::set_icon(const char *icon) { this->icon_c_str_ = icon; }
// Entity Object ID
std::string EntityBase::get_object_id() const {
std::string suffix = "";
#ifdef USE_DEVICES
if (this->device_ != nullptr) {
suffix = "@" + str_sanitize(str_snake_case(this->device_->get_name()));
}
#endif
// Check if `App.get_friendly_name()` is constant or dynamic.
if (!this->flags_.has_own_name && App.is_name_add_mac_suffix_enabled()) {
// `App.get_friendly_name()` is dynamic.
return str_sanitize(str_snake_case(App.get_friendly_name())) + suffix;
return str_sanitize(str_snake_case(App.get_friendly_name()));
} else { // `App.get_friendly_name()` is constant.
if (this->object_id_c_str_ == nullptr) {
return suffix;
return "";
}
return this->object_id_c_str_ + suffix;
return this->object_id_c_str_;
}
}
void EntityBase::set_object_id(const char *object_id) {

View File

@@ -15,7 +15,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, coroutine
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import add, get_variable
from esphome.cpp_generator import MockObj, add, get_variable
from esphome.cpp_types import App
from esphome.helpers import sanitize, snake_case
from esphome.types import ConfigFragmentType, ConfigType
@@ -97,18 +97,65 @@ async def register_parented(var, value):
add(var.set_parent(paren))
async def setup_entity(var, config):
"""Set up generic properties of an Entity"""
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
if CONF_DEVICE_ID in config:
device_id: ID = config[CONF_DEVICE_ID]
device = await get_variable(device_id)
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
from esphome.helpers import fnv1a_32bit_hash
device_id = fnv1a_32bit_hash(device_id_obj.id)
add(var.set_name(config[CONF_NAME]))
# Calculate base object_id
base_object_id: str
if not config[CONF_NAME]:
add(var.set_object_id(sanitize(snake_case(CORE.friendly_name))))
# Use the friendly name if available, otherwise use the device name
if CORE.friendly_name:
base_object_id = sanitize(snake_case(CORE.friendly_name))
else:
base_object_id = sanitize(snake_case(CORE.name))
_LOGGER.debug(
"Entity has empty name, using '%s' as object_id base", base_object_id
)
else:
add(var.set_object_id(sanitize(snake_case(config[CONF_NAME]))))
base_object_id = sanitize(snake_case(config[CONF_NAME]))
# 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))
add(var.set_disabled_by_default(config[CONF_DISABLED_BY_DEFAULT]))
if CONF_INTERNAL in config:
add(var.set_internal(config[CONF_INTERNAL]))

View File

@@ -14,6 +14,8 @@ import sys
import pytest
from esphome.core import CORE
here = Path(__file__).parent
# Configure location of package root
@@ -21,6 +23,13 @@ package_root = here.parent.parent
sys.path.insert(0, package_root.as_posix())
@pytest.fixture(autouse=True)
def reset_core():
"""Reset CORE after each test."""
yield
CORE.reset()
@pytest.fixture
def fixture_path() -> Path:
"""

View File

@@ -0,0 +1,129 @@
"""Test duplicate entity object ID handling."""
import pytest
from esphome.core import CORE
from esphome.helpers import sanitize, snake_case
@pytest.fixture
def setup_test_device() -> None:
"""Set up test device configuration."""
CORE.name = "test-device"
CORE.friendly_name = "Test Device"
def test_unique_key_generation() -> None:
"""Test that unique keys are generated correctly."""
# Test with no device
key1: tuple[int, str, str] = (0, "binary_sensor", "temperature")
assert key1 == (0, "binary_sensor", "temperature")
# Test with device
key2: tuple[int, str, str] = (12345, "sensor", "humidity")
assert key2 == (12345, "sensor", "humidity")
def test_duplicate_tracking() -> None:
"""Test that duplicates are tracked correctly."""
# First occurrence
key: tuple[int, str, str] = (0, "sensor", "temperature")
assert key not in CORE.unique_ids
CORE.unique_ids[key] = 1
assert CORE.unique_ids[key] == 1
# Second occurrence
count: int = CORE.unique_ids[key] + 1
CORE.unique_ids[key] = count
assert CORE.unique_ids[key] == 2
def test_object_id_sanitization() -> None:
"""Test that object IDs are properly sanitized."""
# Test various inputs
assert sanitize(snake_case("Temperature Sensor")) == "temperature_sensor"
assert sanitize(snake_case("Living Room Light!")) == "living_room_light_"
assert sanitize(snake_case("Test-Device")) == "test-device"
assert sanitize(snake_case("")) == ""
def test_suffix_generation() -> None:
"""Test that suffixes are generated correctly."""
base_id: str = "temperature"
# No suffix for first occurrence
object_id_1: str = base_id
assert object_id_1 == "temperature"
# Add suffix for duplicates
count: int = 2
object_id_2: str = f"{base_id}_{count}"
assert object_id_2 == "temperature_2"
count = 3
object_id_3: str = f"{base_id}_{count}"
assert object_id_3 == "temperature_3"
def test_different_platforms_same_name() -> None:
"""Test that same name on different platforms doesn't conflict."""
# Simulate two entities with same name on different platforms
key1: tuple[int, str, str] = (0, "binary_sensor", "status")
key2: tuple[int, str, str] = (0, "text_sensor", "status")
# They should be different keys
assert key1 != key2
# Track them separately
CORE.unique_ids[key1] = 1
CORE.unique_ids[key2] = 1
# Both should be at count 1 (no conflict)
assert CORE.unique_ids[key1] == 1
assert CORE.unique_ids[key2] == 1
def test_different_devices_same_name_platform() -> None:
"""Test that same name+platform on different devices doesn't conflict."""
# Simulate two entities with same name and platform but different devices
key1: tuple[int, str, str] = (12345, "sensor", "temperature")
key2: tuple[int, str, str] = (67890, "sensor", "temperature")
# They should be different keys
assert key1 != key2
# Track them separately
CORE.unique_ids[key1] = 1
CORE.unique_ids[key2] = 1
# Both should be at count 1 (no conflict)
assert CORE.unique_ids[key1] == 1
assert CORE.unique_ids[key2] == 1
def test_empty_name_handling(setup_test_device: None) -> None:
"""Test handling of entities with empty names."""
# When name is empty, it should use the device name
empty_name: str = ""
base_id: str
if not empty_name:
if CORE.friendly_name:
base_id = sanitize(snake_case(CORE.friendly_name))
else:
base_id = sanitize(snake_case(CORE.name))
assert base_id == "test_device" # Uses friendly name
def test_reset_clears_unique_ids() -> None:
"""Test that CORE.reset() clears the unique_ids tracking."""
# Add some tracked IDs
CORE.unique_ids[(0, "sensor", "test")] = 2
CORE.unique_ids[(0, "binary_sensor", "test")] = 3
assert len(CORE.unique_ids) == 2
# Reset should clear them
CORE.reset()
assert len(CORE.unique_ids) == 0