This commit is contained in:
J. Nick Koston
2025-06-24 17:03:23 +02:00
parent e370872ec1
commit c3776240b6
5 changed files with 497 additions and 10 deletions

View File

@@ -17,7 +17,7 @@ from esphome.core import CORE, ID, coroutine
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj, add, get_variable
from esphome.cpp_types import App
from esphome.helpers import sanitize, snake_case
from esphome.entity import get_base_entity_object_id
from esphome.types import ConfigFragmentType, ConfigType
from esphome.util import Registry, RegistryEntry
@@ -122,19 +122,14 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None:
add(var.set_name(config[CONF_NAME]))
# Calculate base object_id
base_object_id: str
# 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)
if not config[CONF_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:
base_object_id = sanitize(snake_case(config[CONF_NAME]))
# Handle duplicates
# Check for duplicates
@@ -156,6 +151,12 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None:
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]))

41
esphome/entity.py Normal file
View File

@@ -0,0 +1,41 @@
"""Entity-related helper functions."""
from esphome.core import CORE
from esphome.helpers import sanitize, snake_case
def get_base_entity_object_id(name: str, friendly_name: str | 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
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 friendly_name:
# Entity has empty name (has_own_name will be false)
# Calculate what the object_id should be
# 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))

View File

@@ -0,0 +1,118 @@
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;
# 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: []

View File

@@ -0,0 +1,187 @@
"""Integration test for duplicate entity handling."""
from __future__ import annotations
import asyncio
from aioesphomeapi import EntityInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_duplicate_entities(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that duplicate entity names are automatically suffixed with _2, _3, _4."""
async with run_compiled(yaml_config), api_client_connected() as client:
# Get device info
device_info = await client.device_info()
assert device_info is not None
# Get devices
devices = device_info.devices
assert len(devices) >= 2, f"Expected at least 2 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)
assert controller_1 is not None, "Controller 1 device not found"
assert controller_2 is not None, "Controller 2 device not found"
# Get entity list
entities = await client.list_entities_services()
all_entities: list[EntityInfo] = []
for entity_list in entities[0]:
if hasattr(entity_list, "object_id"):
all_entities.append(entity_list)
# Group entities by type for easier testing
sensors = [e for e in all_entities if e.__class__.__name__ == "SensorInfo"]
binary_sensors = [
e for e in all_entities if e.__class__.__name__ == "BinarySensorInfo"
]
text_sensors = [
e for e in all_entities if e.__class__.__name__ == "TextSensorInfo"
]
switches = [e for e in all_entities if e.__class__.__name__ == "SwitchInfo"]
# Scenario 1: Check sensors with duplicate "Temperature" names
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"
)
# Scenario 2: Check device-specific sensors don't conflict
device_temp_sensors = [s for s in sensors if s.name == "Device Temperature"]
# 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
]
# 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"
)
# 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 3: Check binary sensors (different platform, 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])
# 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 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])
# 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 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])
# 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)}"
)
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"
)
# Verify we can get states for all entities (ensures they're functional)
loop = asyncio.get_running_loop()
states_future: asyncio.Future[bool] = loop.create_future()
state_count = 0
expected_count = (
len(sensors) + len(binary_sensors) + len(text_sensors) + len(switches)
)
def on_state(state) -> None:
nonlocal state_count
state_count += 1
if state_count >= expected_count and not states_future.done():
states_future.set_result(True)
client.subscribe_states(on_state)
# Wait for all entity states
try:
await asyncio.wait_for(states_future, timeout=10.0)
except asyncio.TimeoutError:
pytest.fail(
f"Did not receive all entity states within 10 seconds. "
f"Expected {expected_count}, received {state_count}"
)

View File

@@ -0,0 +1,140 @@
"""Test get_base_entity_object_id function matches C++ behavior."""
from esphome.core import CORE
from esphome.entity import get_base_entity_object_id
from esphome.helpers import sanitize, snake_case
class TestGetBaseEntityObjectId:
"""Test that get_base_entity_object_id matches C++ EntityBase::get_object_id behavior."""
def test_with_entity_name(self) -> None:
"""Test when entity has its own name - should use entity name."""
# Simple name
assert (
get_base_entity_object_id("Temperature Sensor", None)
== "temperature_sensor"
)
assert (
get_base_entity_object_id("Temperature Sensor", "Device Name")
== "temperature_sensor"
)
# Name with special characters
assert (
get_base_entity_object_id("Temp!@#$%^&*()Sensor", None)
== "temp__________sensor"
)
assert get_base_entity_object_id("Temp-Sensor_123", None) == "temp-sensor_123"
# Already snake_case
assert (
get_base_entity_object_id("temperature_sensor", None)
== "temperature_sensor"
)
# Mixed case
assert (
get_base_entity_object_id("TemperatureSensor", None) == "temperaturesensor"
)
assert (
get_base_entity_object_id("TEMPERATURE SENSOR", None)
== "temperature_sensor"
)
def test_empty_name_with_friendly_name(self) -> None:
"""Test when entity has empty name - should use friendly name."""
# C++ behavior: when has_own_name is false, uses App.get_friendly_name()
assert get_base_entity_object_id("", "Friendly Device") == "friendly_device"
assert (
get_base_entity_object_id("", "Kitchen Controller") == "kitchen_controller"
)
assert get_base_entity_object_id("", "Test-Device_123") == "test-device_123"
# Special characters in friendly name
assert get_base_entity_object_id("", "Device!@#$%") == "device_____"
def test_empty_name_no_friendly_name(self) -> None:
"""Test when entity has empty name and no friendly name - should use device name."""
# Save original values
original_name = getattr(CORE, "name", None)
try:
# Test with CORE.name set
CORE.name = "device-name"
assert get_base_entity_object_id("", None) == "device-name"
CORE.name = "Test Device"
assert get_base_entity_object_id("", None) == "test_device"
finally:
# Restore original value
if original_name is not None:
CORE.name = original_name
def test_edge_cases(self) -> None:
"""Test edge cases."""
# Only spaces
assert get_base_entity_object_id(" ", None) == "___"
# Unicode characters (should be replaced)
assert get_base_entity_object_id("Température", None) == "temp_rature"
assert get_base_entity_object_id("测试", None) == "__"
# Empty string with empty friendly name (empty friendly name is treated as None)
# Falls back to CORE.name
original_name = getattr(CORE, "name", None)
try:
CORE.name = "device"
assert get_base_entity_object_id("", "") == "device"
finally:
if original_name is not None:
CORE.name = original_name
# Very long name (should work fine)
long_name = "a" * 100 + " " + "b" * 100
expected = "a" * 100 + "_" + "b" * 100
assert get_base_entity_object_id(long_name, None) == expected
def test_matches_cpp_helpers(self) -> None:
"""Test that the logic matches using snake_case and sanitize directly."""
test_cases = [
("Temperature Sensor", "temperature_sensor"),
("Living Room Light", "living_room_light"),
("Test-Device_123", "test-device_123"),
("Special!@#Chars", "special___chars"),
("UPPERCASE NAME", "uppercase_name"),
("lowercase name", "lowercase_name"),
("Mixed Case Name", "mixed_case_name"),
(" Spaces ", "___spaces___"),
]
for name, expected in test_cases:
# For non-empty names, verify our function produces same result as direct snake_case + sanitize
assert get_base_entity_object_id(name, None) == sanitize(snake_case(name))
assert get_base_entity_object_id(name, None) == expected
# Empty name is handled specially - it doesn't just use sanitize(snake_case(""))
# Instead it falls back to friendly_name or CORE.name
assert sanitize(snake_case("")) == "" # Direct conversion gives empty string
# But our function returns a fallback
original_name = getattr(CORE, "name", None)
try:
CORE.name = "device"
assert get_base_entity_object_id("", None) == "device" # Uses device name
finally:
if original_name is not None:
CORE.name = original_name
def test_name_add_mac_suffix_behavior(self) -> None:
"""Test behavior related to name_add_mac_suffix.
In C++, when name_add_mac_suffix is enabled and entity has no name,
get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name()))
dynamically. Our function always returns the same result since we're
calculating the base for duplicate tracking.
"""
# The function should always return the same result regardless of
# name_add_mac_suffix setting, as we're calculating the base object_id
assert get_base_entity_object_id("", "Test Device") == "test_device"
assert get_base_entity_object_id("Entity Name", "Test Device") == "entity_name"