1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00

[core] Remove object_id RAM storage - no longer in hot path after #12627 (#12631)

This commit is contained in:
J. Nick Koston
2026-01-02 15:46:01 -10:00
committed by GitHub
parent 916370a943
commit 3cc6810be5
22 changed files with 1213 additions and 117 deletions

View File

@@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main):
)
# Then
assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp
assert 'bs_1->set_name("test bs1",' in main_cpp
assert "bs_1->set_pin(" in main_cpp

View File

@@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/button/test_button.yaml")
# Then
assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp
assert 'wol_1->set_name("wol_test_1",' in main_cpp
assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp

View File

@@ -25,7 +25,7 @@ def test_text_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/text/test_text.yaml")
# Then
assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp
assert 'it_1->set_name("test 1 text",' in main_cpp
def test_text_config_value_internal_set(generate_main):

View File

@@ -25,18 +25,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main):
main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml")
# Then
assert (
'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");'
in main_cpp
)
assert (
'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");'
in main_cpp
)
assert (
'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");'
in main_cpp
)
assert 'ts_1->set_name("Template Text Sensor 1",' in main_cpp
assert 'ts_2->set_name("Template Text Sensor 2",' in main_cpp
assert 'ts_3->set_name("Template Text Sensor 3",' in main_cpp
def test_text_sensor_config_value_internal_set(generate_main):

View File

@@ -51,6 +51,9 @@ if platform.system() == "Windows":
import pty # not available on Windows
# Register assert rewrite for entity_utils so assertions have proper error messages
pytest.register_assert_rewrite("tests.integration.entity_utils")
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
"""Get environment variables for PlatformIO with shared cache."""

View File

@@ -0,0 +1,145 @@
"""Utilities for computing entity object_id in integration tests.
This module contains the algorithm that aioesphomeapi will use to compute
object_id client-side from API data.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case
if TYPE_CHECKING:
from aioesphomeapi import DeviceInfo, EntityInfo
def compute_object_id(name: str) -> str:
"""Compute object_id from name using snake_case + sanitize."""
return sanitize(snake_case(name))
def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool:
"""Infer name_add_mac_suffix from device name ending with MAC suffix."""
mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower()
return device_info.name.endswith(f"-{mac_suffix}")
def _get_name_for_object_id(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> str:
"""Get the name used for object_id computation.
This is the algorithm that aioesphomeapi will use to determine which
name to use for computing object_id client-side from API data.
Args:
entity: The entity to get name for
device_info: Device info from the API
device_id_to_name: Mapping of device_id to device name for sub-devices
Returns:
The name to use for object_id computation
"""
if entity.name:
# Named entity: use entity name
return entity.name
if entity.device_id != 0:
# Empty name on sub-device: use sub-device name
return device_id_to_name[entity.device_id]
if infer_name_add_mac_suffix(device_info) or device_info.friendly_name:
# Empty name on main device with MAC suffix or friendly_name: use friendly_name
# (even if empty - this is bug-for-bug compatibility for MAC suffix case)
return device_info.friendly_name
# Empty name on main device, no friendly_name: use device name
return device_info.name
def compute_entity_object_id(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> str:
"""Compute expected object_id for an entity.
Args:
entity: The entity to compute object_id for
device_info: Device info from the API
device_id_to_name: Mapping of device_id to device name for sub-devices
Returns:
The computed object_id string
"""
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
return compute_object_id(name_for_id)
def compute_entity_hash(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> int:
"""Compute expected object_id hash for an entity.
Args:
entity: The entity to compute hash for
device_info: Device info from the API
device_id_to_name: Mapping of device_id to device name for sub-devices
Returns:
The computed FNV-1 hash
"""
name_for_id = _get_name_for_object_id(entity, device_info, device_id_to_name)
return fnv1_hash_object_id(name_for_id)
def verify_entity_object_id(
entity: EntityInfo,
device_info: DeviceInfo,
device_id_to_name: dict[int, str],
) -> None:
"""Verify an entity's object_id and hash match the expected values.
Args:
entity: The entity to verify
device_info: Device info from the API
device_id_to_name: Mapping of device_id to device name for sub-devices
Raises:
AssertionError: If object_id or hash doesn't match expected value
"""
expected_object_id = compute_entity_object_id(
entity, device_info, device_id_to_name
)
assert entity.object_id == expected_object_id, (
f"object_id mismatch for entity '{entity.name}': "
f"expected '{expected_object_id}', got '{entity.object_id}'"
)
expected_hash = compute_entity_hash(entity, device_info, device_id_to_name)
assert entity.key == expected_hash, (
f"hash mismatch for entity '{entity.name}': "
f"expected {expected_hash:#x}, got {entity.key:#x}"
)
def verify_all_entities(
entities: list[EntityInfo],
device_info: DeviceInfo,
) -> None:
"""Verify all entities have correct object_id and hash values.
Args:
entities: List of entities to verify
device_info: Device info from the API
Raises:
AssertionError: If any entity's object_id or hash doesn't match
"""
# Build device_id -> name lookup from sub-devices
device_id_to_name = {d.device_id: d.name for d in device_info.devices}
for entity in entities:
verify_entity_object_id(entity, device_info, device_id_to_name)

View File

@@ -0,0 +1,76 @@
esphome:
name: fnv1-hash-object-id-test
platformio_options:
build_flags:
- "-DDEBUG"
on_boot:
- lambda: |-
using esphome::fnv1_hash_object_id;
// Test basic lowercase (hash matches Python fnv1_hash_object_id("foo"))
uint32_t hash_foo = fnv1_hash_object_id("foo", 3);
if (hash_foo == 0x408f5e13) {
ESP_LOGI("FNV1_OID", "foo PASSED");
} else {
ESP_LOGE("FNV1_OID", "foo FAILED: 0x%08x != 0x408f5e13", hash_foo);
}
// Test uppercase conversion (should match lowercase)
uint32_t hash_Foo = fnv1_hash_object_id("Foo", 3);
if (hash_Foo == 0x408f5e13) {
ESP_LOGI("FNV1_OID", "upper PASSED");
} else {
ESP_LOGE("FNV1_OID", "upper FAILED: 0x%08x != 0x408f5e13", hash_Foo);
}
// Test space to underscore conversion ("foo bar" -> "foo_bar")
uint32_t hash_space = fnv1_hash_object_id("foo bar", 7);
if (hash_space == 0x3ae35aa1) {
ESP_LOGI("FNV1_OID", "space PASSED");
} else {
ESP_LOGE("FNV1_OID", "space FAILED: 0x%08x != 0x3ae35aa1", hash_space);
}
// Test underscore preserved ("foo_bar")
uint32_t hash_underscore = fnv1_hash_object_id("foo_bar", 7);
if (hash_underscore == 0x3ae35aa1) {
ESP_LOGI("FNV1_OID", "underscore PASSED");
} else {
ESP_LOGE("FNV1_OID", "underscore FAILED: 0x%08x != 0x3ae35aa1", hash_underscore);
}
// Test hyphen preserved ("foo-bar")
uint32_t hash_hyphen = fnv1_hash_object_id("foo-bar", 7);
if (hash_hyphen == 0x438b12e3) {
ESP_LOGI("FNV1_OID", "hyphen PASSED");
} else {
ESP_LOGE("FNV1_OID", "hyphen FAILED: 0x%08x != 0x438b12e3", hash_hyphen);
}
// Test special chars become underscore ("foo!bar" -> "foo_bar")
uint32_t hash_special = fnv1_hash_object_id("foo!bar", 7);
if (hash_special == 0x3ae35aa1) {
ESP_LOGI("FNV1_OID", "special PASSED");
} else {
ESP_LOGE("FNV1_OID", "special FAILED: 0x%08x != 0x3ae35aa1", hash_special);
}
// Test complex name ("My Sensor Name" -> "my_sensor_name")
uint32_t hash_complex = fnv1_hash_object_id("My Sensor Name", 14);
if (hash_complex == 0x2760962a) {
ESP_LOGI("FNV1_OID", "complex PASSED");
} else {
ESP_LOGE("FNV1_OID", "complex FAILED: 0x%08x != 0x2760962a", hash_complex);
}
// Test empty string returns FNV1_OFFSET_BASIS
uint32_t hash_empty = fnv1_hash_object_id("", 0);
if (hash_empty == 0x811c9dc5) {
ESP_LOGI("FNV1_OID", "empty PASSED");
} else {
ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty);
}
host:
api:
logger:

View File

@@ -0,0 +1,125 @@
esphome:
name: object-id-test
friendly_name: Test Device
# Enable MAC suffix - host MAC is 98:35:69:ab:f6:79, suffix is "abf679"
# friendly_name becomes "Test Device abf679"
name_add_mac_suffix: true
# Sub-devices for testing empty-name entities on devices
devices:
- id: sub_device_1
name: Sub Device One
- id: sub_device_2
name: Sub Device Two
host:
api:
logger:
sensor:
# Test 1: Basic name -> object_id = "temperature_sensor"
- platform: template
name: "Temperature Sensor"
id: sensor_basic
lambda: return 42.0;
update_interval: 60s
# Test 2: Uppercase name -> object_id = "uppercase_name"
- platform: template
name: "UPPERCASE NAME"
id: sensor_uppercase
lambda: return 43.0;
update_interval: 60s
# Test 3: Special characters -> object_id = "special__chars_"
- platform: template
name: "Special!@Chars#"
id: sensor_special
lambda: return 44.0;
update_interval: 60s
# Test 4: Hyphen preserved -> object_id = "temp-sensor"
- platform: template
name: "Temp-Sensor"
id: sensor_hyphen
lambda: return 45.0;
update_interval: 60s
# Test 5: Underscore preserved -> object_id = "temp_sensor"
- platform: template
name: "Temp_Sensor"
id: sensor_underscore
lambda: return 46.0;
update_interval: 60s
# Test 6: Mixed case with spaces -> object_id = "living_room_temperature"
- platform: template
name: "Living Room Temperature"
id: sensor_mixed
lambda: return 47.0;
update_interval: 60s
# Test 7: Empty name - uses friendly_name with MAC suffix
# friendly_name = "Test Device abf679" -> object_id = "test_device_abf679"
- platform: template
name: ""
id: sensor_empty_name
lambda: return 48.0;
update_interval: 60s
binary_sensor:
# Test 8: Different platform same conversion rules
- platform: template
name: "Door Open"
id: binary_door
lambda: return true;
# Test 9: Numbers in name -> object_id = "sensor_123"
- platform: template
name: "Sensor 123"
id: binary_numbers
lambda: return false;
switch:
# Test 10: Long name with multiple spaces
- platform: template
name: "My Very Long Switch Name Here"
id: switch_long
lambda: return false;
turn_on_action:
- logger.log: "on"
turn_off_action:
- logger.log: "off"
text_sensor:
# Test 11: Name starting with number (should work fine)
- platform: template
name: "123 Start"
id: text_num_start
lambda: return {"test"};
update_interval: 60s
button:
# Test 12: Named entity on sub-device -> object_id from entity name
- platform: template
name: "Device Button"
id: button_on_device
device_id: sub_device_1
on_press: []
# Test 13: Empty name on sub-device -> object_id from device name
# Device name "Sub Device One" -> object_id = "sub_device_one"
- platform: template
name: ""
id: button_empty_on_device1
device_id: sub_device_1
on_press: []
# Test 14: Empty name on different sub-device
# Device name "Sub Device Two" -> object_id = "sub_device_two"
- platform: template
name: ""
id: button_empty_on_device2
device_id: sub_device_2
on_press: []

View File

@@ -0,0 +1,27 @@
esphome:
name: test-device
# friendly_name set but NO MAC suffix
# Empty-name entity should use friendly_name for object_id
friendly_name: My Friendly Device
host:
api:
logger:
sensor:
# Empty name entity - should use friendly_name for object_id
# friendly_name = "My Friendly Device" -> object_id = "my_friendly_device"
- platform: template
name: ""
id: sensor_empty_name
lambda: return 42.0;
update_interval: 60s
# Named entity for comparison
- platform: template
name: "Temperature"
id: sensor_named
lambda: return 43.0;
update_interval: 60s

View File

@@ -0,0 +1,25 @@
esphome:
name: test-device
# No friendly_name set, no MAC suffix
# OLD behavior: object_id = device name because Python pre-computed with fallback
host:
api:
logger:
sensor:
# Empty name entity - OLD behavior used device name as fallback
- platform: template
name: ""
id: sensor_empty_name
lambda: return 42.0;
update_interval: 60s
# Named entity for comparison
- platform: template
name: "Temperature"
id: sensor_named
lambda: return 43.0;
update_interval: 60s

View File

@@ -0,0 +1,26 @@
esphome:
name: test-device
# No friendly_name set, MAC suffix enabled
# OLD behavior: object_id = "" (empty) because is_object_id_dynamic_() used App.get_friendly_name() directly
name_add_mac_suffix: true
host:
api:
logger:
sensor:
# Empty name entity - OLD behavior produced empty object_id when MAC suffix enabled
- platform: template
name: ""
id: sensor_empty_name
lambda: return 42.0;
update_interval: 60s
# Named entity for comparison
- platform: template
name: "Temperature"
id: sensor_named
lambda: return 43.0;
update_interval: 60s

View File

@@ -0,0 +1,75 @@
"""Integration test for fnv1_hash_object_id function.
This test verifies that the C++ fnv1_hash_object_id() function in
esphome/core/helpers.h produces the same hash values as the Python
fnv1_hash_object_id() function in esphome/helpers.py.
If this test fails, one of the implementations has diverged and needs
to be updated to match the other.
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_fnv1_hash_object_id(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that C++ fnv1_hash_object_id matches Python implementation."""
test_results: dict[str, str] = {}
all_tests_complete = asyncio.Event()
expected_tests = {
"foo",
"upper",
"space",
"underscore",
"hyphen",
"special",
"complex",
"empty",
}
def on_log_line(line: str) -> None:
"""Capture log lines with test results."""
# Strip ANSI escape codes
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
# Look for our test result messages
# Format: "[timestamp][level][FNV1_OID:line]: test_name PASSED"
match = re.search(r"\[FNV1_OID:\d+\]:\s+(\w+)\s+(PASSED|FAILED)", clean_line)
if match:
test_name = match.group(1)
result = match.group(2)
test_results[test_name] = result
if set(test_results.keys()) >= expected_tests:
all_tests_complete.set()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "fnv1-hash-object-id-test"
# Wait for all tests to complete or timeout
try:
await asyncio.wait_for(all_tests_complete.wait(), timeout=2.0)
except TimeoutError:
pytest.fail(f"Tests timed out. Got results for: {set(test_results.keys())}")
# Verify all tests passed
for test_name in expected_tests:
assert test_name in test_results, f"{test_name} test not found"
assert test_results[test_name] == "PASSED", (
f"{test_name} test failed - C++ and Python hash mismatch"
)

View File

@@ -0,0 +1,176 @@
"""Integration test to verify object_id from API matches Python computation.
This test verifies a three-way match between:
1. C++ object_id generation (get_object_id_to using to_sanitized_char/to_snake_case_char)
2. C++ hash generation (fnv1_hash_object_id in helpers.h)
3. Python computation (sanitize/snake_case in helpers.py, fnv1_hash_object_id)
The API response contains C++ computed values, so verifying API == Python
implicitly verifies C++ == Python == API for both object_id and hash.
This is important for the planned migration to remove object_id from the API
protocol and have clients (like aioesphomeapi) compute it from the name.
See: https://github.com/esphome/backlog/issues/76
Test cases covered:
- Named entities with various characters (uppercase, special chars, hyphens, etc.)
- Empty-name entities on main device (uses device's friendly_name with MAC suffix)
- Empty-name entities on sub-devices (uses sub-device's name)
- Named entities on sub-devices (uses entity name, not device name)
- MAC suffix handling (name_add_mac_suffix modifies friendly_name at runtime)
- Both object_id string and hash (key) verification
"""
from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_object_id
from .entity_utils import compute_object_id, verify_all_entities
from .types import APIClientConnectedFactory, RunCompiledFunction
# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679"
MAC_SUFFIX = "abf679"
# Expected entities with their own names and expected object_ids
# Format: (entity_name, expected_object_id)
NAMED_ENTITIES = [
# sensor platform
("Temperature Sensor", "temperature_sensor"),
("UPPERCASE NAME", "uppercase_name"),
("Special!@Chars#", "special__chars_"),
("Temp-Sensor", "temp-sensor"),
("Temp_Sensor", "temp_sensor"),
("Living Room Temperature", "living_room_temperature"),
# binary_sensor platform
("Door Open", "door_open"),
("Sensor 123", "sensor_123"),
# switch platform
("My Very Long Switch Name Here", "my_very_long_switch_name_here"),
# text_sensor platform
("123 Start", "123_start"),
# button platform - named entity on sub-device (uses entity name, not device name)
("Device Button", "device_button"),
]
# Sub-device names and their expected object_ids for empty-name entities
# Format: (device_name, expected_object_id)
SUB_DEVICE_EMPTY_NAME_ENTITIES = [
("Sub Device One", "sub_device_one"),
("Sub Device Two", "sub_device_two"),
]
@pytest.mark.asyncio
async def test_object_id_api_verification(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that object_id from API matches Python computation.
Tests:
1. Named entities - object_id computed from entity name
2. Empty-name entities - object_id computed from friendly_name (with MAC suffix)
3. Hash verification - key can be computed from name
4. Generic verification - all entities can have object_id computed from API data
"""
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
# Device name should include MAC suffix (hyphen separator)
assert device_info.name == f"object-id-test-{MAC_SUFFIX}", (
f"Device name mismatch: got '{device_info.name}'"
)
# Friendly name should include MAC suffix (space separator)
expected_friendly_name = f"Test Device {MAC_SUFFIX}"
assert device_info.friendly_name == expected_friendly_name, (
f"Friendly name mismatch: got '{device_info.friendly_name}'"
)
# Get all entities
entities, _ = await client.list_entities_services()
# Create a map of entity names to entity info
entity_map = {}
for entity in entities:
entity_map[entity.name] = entity
# === Test 1: Verify each named entity ===
for entity_name, expected_object_id in NAMED_ENTITIES:
assert entity_name in entity_map, (
f"Entity '{entity_name}' not found in API response. "
f"Available: {list(entity_map.keys())}"
)
entity = entity_map[entity_name]
# Verify object_id matches expected
assert entity.object_id == expected_object_id, (
f"Entity '{entity_name}': object_id mismatch. "
f"API returned '{entity.object_id}', expected '{expected_object_id}'"
)
# Verify Python computation matches
computed = compute_object_id(entity_name)
assert computed == expected_object_id, (
f"Entity '{entity_name}': Python computation mismatch. "
f"Computed '{computed}', expected '{expected_object_id}'"
)
# Verify hash can be computed from the name
hash_from_name = fnv1_hash_object_id(entity_name)
assert hash_from_name == entity.key, (
f"Entity '{entity_name}': hash mismatch. "
f"Python hash {hash_from_name:#x}, API key {entity.key:#x}"
)
# === Test 2: Verify empty-name entities ===
# Empty-name entities have name="" in API, object_id comes from:
# - Main device: friendly_name (with MAC suffix)
# - Sub-device: device name
# Get all empty-name entities
empty_name_entities = [e for e in entities if e.name == ""]
# We expect 3: 1 on main device, 2 on sub-devices
assert len(empty_name_entities) == 3, (
f"Expected 3 empty-name entities, got {len(empty_name_entities)}"
)
# Build device_id -> device_name map from device_info
device_id_to_name = {d.device_id: d.name for d in device_info.devices}
# Verify each empty-name entity
for entity in empty_name_entities:
if entity.device_id == 0:
# Main device - uses friendly_name with MAC suffix
expected_name = expected_friendly_name
else:
# Sub-device - uses device name
assert entity.device_id in device_id_to_name, (
f"Entity device_id {entity.device_id} not found in devices"
)
expected_name = device_id_to_name[entity.device_id]
expected_object_id = compute_object_id(expected_name)
assert entity.object_id == expected_object_id, (
f"Empty-name entity (device_id={entity.device_id}): object_id mismatch. "
f"API: '{entity.object_id}', expected: '{expected_object_id}' "
f"(from name '{expected_name}')"
)
# Verify hash matches
expected_hash = fnv1_hash_object_id(expected_name)
assert entity.key == expected_hash, (
f"Empty-name entity (device_id={entity.device_id}): hash mismatch. "
f"API key: {entity.key:#x}, expected: {expected_hash:#x}"
)
# === Test 3: Verify ALL entities using the algorithm from entity_utils ===
# This uses the algorithm that aioesphomeapi will use to compute object_id
# client-side from API data.
verify_all_entities(entities, device_info)

View File

@@ -0,0 +1,81 @@
"""Integration test for object_id with friendly_name but no MAC suffix.
This test covers Branch 4 of the algorithm:
- Empty name on main device
- NO MAC suffix enabled
- friendly_name IS set
- Result: use friendly_name for object_id
"""
from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_object_id
from .entity_utils import (
compute_object_id,
infer_name_add_mac_suffix,
verify_all_entities,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_object_id_friendly_name_no_mac_suffix(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test object_id when friendly_name is set but no MAC suffix.
This covers Branch 4 of the algorithm:
- Empty name entity
- name_add_mac_suffix = false (or not set)
- friendly_name = "My Friendly Device"
- Expected: object_id = "my_friendly_device"
"""
async with run_compiled(yaml_config), api_client_connected() as client:
device_info = await client.device_info()
assert device_info is not None
# Device name should NOT include MAC suffix
assert device_info.name == "test-device"
# Friendly name should be set
assert device_info.friendly_name == "My Friendly Device"
entities, _ = await client.list_entities_services()
# Find the empty-name entity
empty_name_entities = [e for e in entities if e.name == ""]
assert len(empty_name_entities) == 1
entity = empty_name_entities[0]
# Should use friendly_name for object_id (Branch 4)
expected_object_id = compute_object_id("My Friendly Device")
assert expected_object_id == "my_friendly_device" # Verify our expectation
assert entity.object_id == expected_object_id, (
f"Expected object_id '{expected_object_id}' from friendly_name, "
f"got '{entity.object_id}'"
)
# Hash should match friendly_name
expected_hash = fnv1_hash_object_id("My Friendly Device")
assert entity.key == expected_hash, (
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
)
# Named entity should work normally
named_entities = [e for e in entities if e.name == "Temperature"]
assert len(named_entities) == 1
assert named_entities[0].object_id == "temperature"
# Verify our inference: no MAC suffix in this test
assert not infer_name_add_mac_suffix(device_info), (
"Device name should NOT have MAC suffix"
)
# Verify the full algorithm from entity_utils works for ALL entities
verify_all_entities(entities, device_info)

View File

@@ -0,0 +1,140 @@
"""Integration tests for object_id when friendly_name is not set.
These tests verify bug-for-bug compatibility with the old behavior:
1. With MAC suffix enabled + no friendly_name:
- OLD: is_object_id_dynamic_() was true, used App.get_friendly_name() directly
- OLD: object_id = "" (empty) because friendly_name was empty
- NEW: Must maintain same behavior for compatibility
2. Without MAC suffix + no friendly_name:
- OLD: is_object_id_dynamic_() was false, used pre-computed object_id_c_str_
- OLD: Python computed object_id with fallback to device name
- NEW: Must maintain same behavior (object_id = device name)
"""
from __future__ import annotations
import pytest
from esphome.helpers import fnv1_hash_object_id
from .entity_utils import compute_object_id, verify_all_entities
from .types import APIClientConnectedFactory, RunCompiledFunction
# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679"
MAC_SUFFIX = "abf679"
# FNV1 offset basis - hash of empty string
FNV1_OFFSET_BASIS = 2166136261
@pytest.mark.asyncio
async def test_object_id_no_friendly_name_with_mac_suffix(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test object_id when friendly_name not set but MAC suffix enabled.
OLD behavior (bug-for-bug compatibility):
- is_object_id_dynamic_() returned true (no own name AND mac suffix enabled)
- format_dynamic_object_id() used App.get_friendly_name() directly
- Since friendly_name was empty, object_id was empty
This was arguably a bug, but we maintain it for compatibility.
"""
async with run_compiled(yaml_config), api_client_connected() as client:
device_info = await client.device_info()
assert device_info is not None
# Device name should include MAC suffix
expected_device_name = f"test-device-{MAC_SUFFIX}"
assert device_info.name == expected_device_name
# Friendly name should be empty (not set in config)
assert device_info.friendly_name == ""
entities, _ = await client.list_entities_services()
# Find the empty-name entity
empty_name_entities = [e for e in entities if e.name == ""]
assert len(empty_name_entities) == 1
entity = empty_name_entities[0]
# OLD behavior: object_id was empty because App.get_friendly_name() was empty
# This is bug-for-bug compatibility
assert entity.object_id == "", (
f"Expected empty object_id for bug-for-bug compatibility, "
f"got '{entity.object_id}'"
)
# Hash should be FNV1_OFFSET_BASIS (hash of empty string)
assert entity.key == FNV1_OFFSET_BASIS, (
f"Expected hash of empty string ({FNV1_OFFSET_BASIS:#x}), "
f"got {entity.key:#x}"
)
# Named entity should work normally
named_entities = [e for e in entities if e.name == "Temperature"]
assert len(named_entities) == 1
assert named_entities[0].object_id == "temperature"
# Verify the full algorithm from entity_utils works for ALL entities
verify_all_entities(entities, device_info)
@pytest.mark.asyncio
async def test_object_id_no_friendly_name_no_mac_suffix(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test object_id when friendly_name not set and no MAC suffix.
OLD behavior:
- is_object_id_dynamic_() returned false (mac suffix not enabled)
- Used object_id_c_str_ which was pre-computed in Python
- Python used get_base_entity_object_id() with fallback to CORE.name
Result: object_id = sanitize(snake_case(device_name))
"""
async with run_compiled(yaml_config), api_client_connected() as client:
device_info = await client.device_info()
assert device_info is not None
# Device name should NOT include MAC suffix
assert device_info.name == "test-device"
# Friendly name should be empty (not set in config)
assert device_info.friendly_name == ""
entities, _ = await client.list_entities_services()
# Find the empty-name entity
empty_name_entities = [e for e in entities if e.name == ""]
assert len(empty_name_entities) == 1
entity = empty_name_entities[0]
# OLD behavior: object_id was computed from device name
expected_object_id = compute_object_id("test-device")
assert entity.object_id == expected_object_id, (
f"Expected object_id '{expected_object_id}' from device name, "
f"got '{entity.object_id}'"
)
# Hash should match device name
expected_hash = fnv1_hash_object_id("test-device")
assert entity.key == expected_hash, (
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
)
# Named entity should work normally
named_entities = [e for e in entities if e.name == "Temperature"]
assert len(named_entities) == 1
assert named_entities[0].object_id == "temperature"
# Verify the full algorithm from entity_utils works for ALL entities
verify_all_entities(entities, device_info)

View File

@@ -27,13 +27,9 @@ from esphome.helpers import sanitize, snake_case
from .common import load_config_from_fixture
# Pre-compiled regex patterns for extracting object IDs from expressions
# Matches both old format: .set_object_id("obj_id")
# and new format: .set_name_and_object_id("name", "obj_id")
OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)')
COMBINED_PATTERN = re.compile(
r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)'
)
# Pre-compiled regex pattern for extracting names from set_name calls
# Matches: .set_name("name", hash) or .set_name("name")
SET_NAME_PATTERN = re.compile(r'\.set_name\(["\']([^"\']*)["\']')
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers"
@@ -276,14 +272,21 @@ def setup_test_environment() -> Generator[list[str], None, None]:
def extract_object_id_from_expressions(expressions: list[str]) -> str | None:
"""Extract the object ID that was set from the generated expressions."""
"""Extract the object ID that would be computed from set_name calls.
Since object_id is now computed from the name (via snake_case + sanitize),
we extract the name from set_name() calls and compute the expected object_id.
For empty names, we fall back to CORE.friendly_name or CORE.name.
"""
for expr in expressions:
# First try new combined format: .set_name_and_object_id("name", "obj_id")
if match := COMBINED_PATTERN.search(expr):
return match.group(1)
# Fall back to old format: .set_object_id("obj_id")
if match := OBJECT_ID_PATTERN.search(expr):
return match.group(1)
if match := SET_NAME_PATTERN.search(expr):
name = match.group(1)
if name:
return sanitize(snake_case(name))
# Empty name - fall back to friendly_name or device name
if CORE.friendly_name:
return sanitize(snake_case(CORE.friendly_name))
return sanitize(snake_case(CORE.name)) if CORE.name else None
return None
@@ -757,3 +760,140 @@ def test_entity_duplicate_validator_same_name_no_enhanced_message() -> None:
r"Each entity on a device must have a unique name within its platform\.$",
):
validator(config2)
@pytest.mark.asyncio
async def test_setup_entity_empty_name_with_device(
setup_test_environment: list[str],
) -> None:
"""Test setup_entity with empty entity name on a sub-device.
For empty-name entities, Python passes 0 and C++ calculates the hash
at runtime from the device's actual name.
"""
added_expressions = setup_test_environment
# Mock get_variable to return a mock device
original_get_variable = entity_helpers.get_variable
async def mock_get_variable(id_: ID) -> MockObj:
return MockObj("sub_device_1")
entity_helpers.get_variable = mock_get_variable
var = MockObj("sensor1")
device_id = ID("sub_device_1", type="Device")
config = {
CONF_NAME: "",
CONF_DISABLED_BY_DEFAULT: False,
CONF_DEVICE_ID: device_id,
}
await setup_entity(var, config, "sensor")
entity_helpers.get_variable = original_get_variable
# Check that set_device was called
assert any("sensor1.set_device" in expr for expr in added_expressions)
# For empty-name entities, Python passes 0 - C++ calculates hash at runtime
assert any('set_name("", 0)' in expr for expr in added_expressions), (
f"Expected set_name with hash 0, got {added_expressions}"
)
@pytest.mark.asyncio
async def test_setup_entity_empty_name_with_mac_suffix(
setup_test_environment: list[str],
) -> None:
"""Test setup_entity with empty name and MAC suffix enabled.
For empty-name entities, Python passes 0 and C++ calculates the hash
at runtime from friendly_name (bug-for-bug compatibility).
"""
added_expressions = setup_test_environment
# Set up CORE.config with name_add_mac_suffix enabled
CORE.config = {"name_add_mac_suffix": True}
# Set friendly_name to a specific value
CORE.friendly_name = "My Device"
var = MockObj("sensor1")
config = {
CONF_NAME: "",
CONF_DISABLED_BY_DEFAULT: False,
}
await setup_entity(var, config, "sensor")
# For empty-name entities, Python passes 0 - C++ calculates hash at runtime
assert any('set_name("", 0)' in expr for expr in added_expressions), (
f"Expected set_name with hash 0, got {added_expressions}"
)
@pytest.mark.asyncio
async def test_setup_entity_empty_name_with_mac_suffix_no_friendly_name(
setup_test_environment: list[str],
) -> None:
"""Test setup_entity with empty name, MAC suffix enabled, but no friendly_name.
For empty-name entities, Python passes 0 and C++ calculates the hash
at runtime. In this case C++ will hash the empty friendly_name
(bug-for-bug compatibility).
"""
added_expressions = setup_test_environment
# Set up CORE.config with name_add_mac_suffix enabled
CORE.config = {"name_add_mac_suffix": True}
# Set friendly_name to empty
CORE.friendly_name = ""
var = MockObj("sensor1")
config = {
CONF_NAME: "",
CONF_DISABLED_BY_DEFAULT: False,
}
await setup_entity(var, config, "sensor")
# For empty-name entities, Python passes 0 - C++ calculates hash at runtime
assert any('set_name("", 0)' in expr for expr in added_expressions), (
f"Expected set_name with hash 0, got {added_expressions}"
)
@pytest.mark.asyncio
async def test_setup_entity_empty_name_no_mac_suffix_no_friendly_name(
setup_test_environment: list[str],
) -> None:
"""Test setup_entity with empty name, no MAC suffix, and no friendly_name.
For empty-name entities, Python passes 0 and C++ calculates the hash
at runtime from the device name.
"""
added_expressions = setup_test_environment
# No MAC suffix (either not set or False)
CORE.config = {}
# No friendly_name
CORE.friendly_name = ""
# Device name is set
CORE.name = "my-test-device"
var = MockObj("sensor1")
config = {
CONF_NAME: "",
CONF_DISABLED_BY_DEFAULT: False,
}
await setup_entity(var, config, "sensor")
# For empty-name entities, Python passes 0 - C++ calculates hash at runtime
assert any('set_name("", 0)' in expr for expr in added_expressions), (
f"Expected set_name with hash 0, got {added_expressions}"
)

View File

@@ -279,6 +279,77 @@ def test_sanitize(text, expected):
assert actual == expected
@pytest.mark.parametrize(
("name", "expected_hash"),
(
# Basic strings - hash of sanitize(snake_case(name))
("foo", 0x408F5E13),
("Foo", 0x408F5E13), # Same as "foo" (lowercase)
("FOO", 0x408F5E13), # Same as "foo" (lowercase)
# Spaces become underscores
("foo bar", 0x3AE35AA1), # Transforms to "foo_bar"
("Foo Bar", 0x3AE35AA1), # Same (lowercase + underscore)
# Already snake_case
("foo_bar", 0x3AE35AA1),
# Special chars become underscores
("foo!bar", 0x3AE35AA1), # Transforms to "foo_bar"
("foo@bar", 0x3AE35AA1), # Transforms to "foo_bar"
# Hyphens are preserved
("foo-bar", 0x438B12E3),
# Numbers are preserved
("foo123", 0xF3B0067D),
# Empty string
("", 0x811C9DC5), # FNV1_OFFSET_BASIS (no chars processed)
# Single char
("a", 0x050C5D7E),
# Mixed case and spaces
("My Sensor Name", 0x2760962A), # Transforms to "my_sensor_name"
),
)
def test_fnv1_hash_object_id(name, expected_hash):
"""Test fnv1_hash_object_id produces expected hashes.
These expected values were computed to match the C++ implementation
in esphome/core/helpers.h. If this test fails after modifying either
implementation, ensure both Python and C++ versions stay in sync.
"""
actual = helpers.fnv1_hash_object_id(name)
assert actual == expected_hash
def _fnv1_hash_py(s: str) -> int:
"""Python implementation of FNV-1 hash for verification."""
hash_val = 2166136261 # FNV1_OFFSET_BASIS
for c in s:
hash_val = (hash_val * 16777619) & 0xFFFFFFFF # FNV1_PRIME
hash_val ^= ord(c)
return hash_val
@pytest.mark.parametrize(
"name",
(
"Simple",
"With Space",
"MixedCase",
"special!@#chars",
"already_snake_case",
"123numbers",
),
)
def test_fnv1_hash_object_id_matches_manual_calculation(name):
"""Verify fnv1_hash_object_id matches snake_case + sanitize + standard FNV-1."""
# Manual calculation: snake_case -> sanitize -> fnv1_hash
transformed = helpers.sanitize(helpers.snake_case(name))
expected = _fnv1_hash_py(transformed)
# Direct calculation via fnv1_hash_object_id
actual = helpers.fnv1_hash_object_id(name)
assert actual == expected
@pytest.mark.parametrize(
"text, expected",
((["127.0.0.1", "fe80::1", "2001::2"], ["2001::2", "127.0.0.1", "fe80::1"]),),