mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 03:16:37 +00:00
[core] Partially revert "Hash entity keys from the raw name to fix collisions" (#18361)
This commit is contained in:
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from esphome.helpers import fnv1_hash_name, sanitize, snake_case
|
||||
from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi import DeviceInfo, EntityInfo
|
||||
@@ -25,16 +25,15 @@ def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool:
|
||||
return device_info.name.endswith(f"-{mac_suffix}")
|
||||
|
||||
|
||||
def _resolve_entity_name(
|
||||
def _get_name_for_object_id(
|
||||
entity: EntityInfo,
|
||||
device_info: DeviceInfo,
|
||||
device_id_to_name: dict[int, str],
|
||||
) -> str:
|
||||
"""Resolve the effective name for an entity.
|
||||
"""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; the same
|
||||
name is what the device hashes into the entity key.
|
||||
name to use for computing object_id client-side from API data.
|
||||
|
||||
Args:
|
||||
entity: The entity to get name for
|
||||
@@ -73,27 +72,27 @@ def compute_entity_object_id(
|
||||
Returns:
|
||||
The computed object_id string
|
||||
"""
|
||||
name = _resolve_entity_name(entity, device_info, device_id_to_name)
|
||||
return compute_object_id(name)
|
||||
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_key(
|
||||
def compute_entity_hash(
|
||||
entity: EntityInfo,
|
||||
device_info: DeviceInfo,
|
||||
device_id_to_name: dict[int, str],
|
||||
) -> int:
|
||||
"""Compute expected entity key for an entity.
|
||||
"""Compute expected object_id hash for an entity.
|
||||
|
||||
Args:
|
||||
entity: The entity to compute the key for
|
||||
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 of the raw name
|
||||
The computed FNV-1 hash
|
||||
"""
|
||||
name = _resolve_entity_name(entity, device_info, device_id_to_name)
|
||||
return fnv1_hash_name(name)
|
||||
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(
|
||||
@@ -119,7 +118,7 @@ def verify_entity_object_id(
|
||||
f"expected '{expected_object_id}', got '{entity.object_id}'"
|
||||
)
|
||||
|
||||
expected_hash = compute_entity_key(entity, device_info, device_id_to_name)
|
||||
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}"
|
||||
|
||||
@@ -71,38 +71,6 @@ esphome:
|
||||
ESP_LOGE("FNV1_OID", "empty FAILED: 0x%08x != 0x811c9dc5", hash_empty);
|
||||
}
|
||||
|
||||
// Raw name hash: matches Python fnv1_hash_name("My Sensor Name")
|
||||
uint32_t hash_raw = esphome::fnv1_hash_bytes("My Sensor Name", 14);
|
||||
if (hash_raw == 0x8cec6fb0) {
|
||||
ESP_LOGI("FNV1_OID", "raw PASSED");
|
||||
} else {
|
||||
ESP_LOGE("FNV1_OID", "raw FAILED: 0x%08x != 0x8cec6fb0", hash_raw);
|
||||
}
|
||||
|
||||
// Raw name hash over UTF-8 bytes: matches Python fnv1_hash_name("Température")
|
||||
uint32_t hash_raw_utf8 = esphome::fnv1_hash_bytes("Temp\xc3\xa9rature", 12);
|
||||
if (hash_raw_utf8 == 0x531a74aa) {
|
||||
ESP_LOGI("FNV1_OID", "raw_utf8 PASSED");
|
||||
} else {
|
||||
ESP_LOGE("FNV1_OID", "raw_utf8 FAILED: 0x%08x != 0x531a74aa", hash_raw_utf8);
|
||||
}
|
||||
|
||||
// Old-key UTF-8 variant: matches Python fnv1_hash_object_id("Température")
|
||||
uint32_t hash_old_utf8 = esphome::fnv1_hash_object_id("Temp\xc3\xa9rature", 12, true);
|
||||
if (hash_old_utf8 == 0x965698f3) {
|
||||
ESP_LOGI("FNV1_OID", "old_utf8 PASSED");
|
||||
} else {
|
||||
ESP_LOGE("FNV1_OID", "old_utf8 FAILED: 0x%08x != 0x965698f3", hash_old_utf8);
|
||||
}
|
||||
|
||||
// Old-key UTF-8 variant with multi-byte only name: Python fnv1_hash_object_id("温度")
|
||||
uint32_t hash_old_cjk = esphome::fnv1_hash_object_id("\xe6\xb8\xa9\xe5\xba\xa6", 6, true);
|
||||
if (hash_old_cjk == 0x3276cb9f) {
|
||||
ESP_LOGI("FNV1_OID", "old_cjk PASSED");
|
||||
} else {
|
||||
ESP_LOGE("FNV1_OID", "old_cjk FAILED: 0x%08x != 0x3276cb9f", hash_old_cjk);
|
||||
}
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
@@ -156,17 +156,10 @@ button:
|
||||
ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str());
|
||||
ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str());
|
||||
ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str());
|
||||
// Log preference key bases for entities that actually store preferences.
|
||||
// This is the key base make_entity_preference() uses: entity key XOR device id.
|
||||
ESP_LOGI("test", "Device A Switch Pref Hash: %u",
|
||||
id(light_device_a).get_entity_key() ^ id(light_device_a).get_device_id_or_zero());
|
||||
ESP_LOGI("test", "Device B Switch Pref Hash: %u",
|
||||
id(light_device_b).get_entity_key() ^ id(light_device_b).get_device_id_or_zero());
|
||||
ESP_LOGI("test", "Main Switch Pref Hash: %u",
|
||||
id(light_main).get_entity_key() ^ id(light_main).get_device_id_or_zero());
|
||||
ESP_LOGI("test", "Device A Number Pref Hash: %u",
|
||||
id(setpoint_device_a).get_entity_key() ^ id(setpoint_device_a).get_device_id_or_zero());
|
||||
ESP_LOGI("test", "Device B Number Pref Hash: %u",
|
||||
id(setpoint_device_b).get_entity_key() ^ id(setpoint_device_b).get_device_id_or_zero());
|
||||
ESP_LOGI("test", "Main Number Pref Hash: %u",
|
||||
id(setpoint_main).get_entity_key() ^ id(setpoint_main).get_device_id_or_zero());
|
||||
// Log preference hashes for entities that actually store preferences
|
||||
ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash());
|
||||
ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash());
|
||||
ESP_LOGI("test", "Main Switch Pref Hash: %u", id(light_main).get_preference_hash());
|
||||
ESP_LOGI("test", "Device A Number Pref Hash: %u", id(setpoint_device_a).get_preference_hash());
|
||||
ESP_LOGI("test", "Device B Number Pref Hash: %u", id(setpoint_device_b).get_preference_hash());
|
||||
ESP_LOGI("test", "Main Number Pref Hash: %u", id(setpoint_main).get_preference_hash());
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
esphome:
|
||||
name: host-pref-key-migration
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
switch:
|
||||
- platform: template
|
||||
id: test_switch_restore
|
||||
name: Test Switch
|
||||
optimistic: true
|
||||
restore_mode: RESTORE_DEFAULT_OFF
|
||||
|
||||
number:
|
||||
- platform: template
|
||||
id: test_number_restore
|
||||
name: Test Number
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
initial_value: 1.0
|
||||
min_value: 0
|
||||
max_value: 100
|
||||
step: 0.5
|
||||
|
||||
text:
|
||||
- platform: template
|
||||
id: test_text_restore
|
||||
name: Test Text
|
||||
mode: text
|
||||
optimistic: true
|
||||
restore_value: true
|
||||
initial_value: fallback
|
||||
min_length: 0
|
||||
max_length: 20
|
||||
@@ -25,25 +25,15 @@ def clear_host_prefs(device_name: str) -> None:
|
||||
host_prefs_path(device_name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_host_prefs(device_name: str, entries: dict[int, bytes]) -> Path:
|
||||
"""Write preference entries, replacing the file's contents.
|
||||
|
||||
Returns the path that was written.
|
||||
"""
|
||||
payload = b""
|
||||
for key, data in entries.items():
|
||||
if len(data) > 255:
|
||||
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
|
||||
payload += struct.pack("<IB", key, len(data)) + data
|
||||
path = host_prefs_path(device_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
|
||||
|
||||
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
|
||||
"""Write a single preference entry, replacing the file's contents.
|
||||
|
||||
Returns the path that was written.
|
||||
"""
|
||||
return write_host_prefs(device_name, {key: data})
|
||||
if len(data) > 255:
|
||||
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
|
||||
path = host_prefs_path(device_name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = struct.pack("<IB", key, len(data)) + data
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
|
||||
@@ -37,10 +37,6 @@ async def test_fnv1_hash_object_id(
|
||||
"special",
|
||||
"complex",
|
||||
"empty",
|
||||
"raw",
|
||||
"raw_utf8",
|
||||
"old_utf8",
|
||||
"old_cjk",
|
||||
}
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
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++ entity key generation (fnv1_hash of the raw name in helpers.h)
|
||||
3. Python computation (sanitize/snake_case and fnv1_hash_name in helpers.py)
|
||||
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.
|
||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.helpers import fnv1_hash_name
|
||||
from esphome.helpers import fnv1_hash_object_id
|
||||
|
||||
from .entity_utils import compute_object_id, verify_all_entities
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -123,7 +123,7 @@ async def test_object_id_api_verification(
|
||||
)
|
||||
|
||||
# Verify hash can be computed from the name
|
||||
hash_from_name = fnv1_hash_name(entity_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}"
|
||||
@@ -164,7 +164,7 @@ async def test_object_id_api_verification(
|
||||
)
|
||||
|
||||
# Verify hash matches
|
||||
expected_hash = fnv1_hash_name(expected_name)
|
||||
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}"
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.helpers import fnv1_hash_name
|
||||
from esphome.helpers import fnv1_hash_object_id
|
||||
|
||||
from .entity_utils import (
|
||||
compute_object_id,
|
||||
@@ -62,7 +62,7 @@ async def test_object_id_friendly_name_no_mac_suffix(
|
||||
)
|
||||
|
||||
# Hash should match friendly_name
|
||||
expected_hash = fnv1_hash_name("My Friendly Device")
|
||||
expected_hash = fnv1_hash_object_id("My Friendly Device")
|
||||
assert entity.key == expected_hash, (
|
||||
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.helpers import fnv1_hash_name
|
||||
from esphome.helpers import fnv1_hash_object_id
|
||||
|
||||
from .entity_utils import compute_object_id, verify_all_entities
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
@@ -96,7 +96,7 @@ async def test_object_id_no_friendly_name_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_name() with fallback to CORE.name
|
||||
- Python used get_base_entity_object_id() with fallback to CORE.name
|
||||
|
||||
Result: object_id = sanitize(snake_case(device_name))
|
||||
"""
|
||||
@@ -126,7 +126,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix(
|
||||
)
|
||||
|
||||
# Hash should match device name
|
||||
expected_hash = fnv1_hash_name("test-device")
|
||||
expected_hash = fnv1_hash_object_id("test-device")
|
||||
assert entity.key == expected_hash, (
|
||||
f"Expected hash {expected_hash:#x}, got {entity.key:#x}"
|
||||
)
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"""Integration test for entity preference key migration.
|
||||
|
||||
Entity keys are now the FNV-1 hash of the raw name instead of the sanitized
|
||||
object_id (https://github.com/esphome/backlog/issues/85). On key-lookup
|
||||
preference backends, make_entity_preference() must move data stored under the
|
||||
old key to the new key, so devices keep their restored state after upgrading.
|
||||
|
||||
This test seeds the host preferences file the way a pre-migration firmware
|
||||
would have written it and verifies:
|
||||
1. Data stored under the OLD key is restored (migration happened, no data loss)
|
||||
2. Data already stored under the NEW key is never overwritten by old data
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from aioesphomeapi import (
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SwitchInfo,
|
||||
SwitchState,
|
||||
TextInfo,
|
||||
TextState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from esphome.helpers import fnv1_hash, fnv1_hash_name, fnv1_hash_object_id
|
||||
|
||||
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
|
||||
from .host_prefs import clear_host_prefs, write_host_prefs
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import CompileFunction, ConfigWriter
|
||||
|
||||
DEVICE_NAME = "host-pref-key-migration"
|
||||
|
||||
# The pre-migration preference key was the sanitized object_id hash; the new
|
||||
# key is the raw-name hash. All entities are on the main device (device_id 0)
|
||||
# and their preferences use no version salt, so the key is just the hash.
|
||||
SWITCH_OLD_KEY = fnv1_hash_object_id("Test Switch")
|
||||
SWITCH_NEW_KEY = fnv1_hash_name("Test Switch")
|
||||
NUMBER_OLD_KEY = fnv1_hash_object_id("Test Number")
|
||||
NUMBER_NEW_KEY = fnv1_hash_name("Test Number")
|
||||
|
||||
# template_text salts its key with the length limits and pattern hash; this must
|
||||
# match TemplateText::setup() in template_text.cpp (min_length 0, max_length 20,
|
||||
# no pattern configured)
|
||||
TEXT_KEY_EXTRA = (0 << 2) + (20 << 4) + (fnv1_hash("") << 6)
|
||||
TEXT_OLD_KEY = (fnv1_hash_object_id("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
|
||||
TEXT_NEW_KEY = (fnv1_hash_name("Test Text") + TEXT_KEY_EXTRA) & 0xFFFFFFFF
|
||||
|
||||
# TextSaver<20> stores a length-prefixed buffer of max_length + 1 bytes
|
||||
TEXT_MAX_LENGTH = 20
|
||||
|
||||
|
||||
def text_pref_payload(value: str) -> bytes:
|
||||
"""Build the length-prefixed buffer TextSaver stores for a value."""
|
||||
data = value.encode("utf-8")
|
||||
assert len(data) <= TEXT_MAX_LENGTH
|
||||
return bytes([len(data)]) + data + b"\x00" * (TEXT_MAX_LENGTH - len(data))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preference_key_migration(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""Test that preferences stored under the old key survive the upgrade."""
|
||||
port, port_socket = reserved_tcp_port
|
||||
|
||||
assert SWITCH_OLD_KEY != SWITCH_NEW_KEY
|
||||
assert NUMBER_OLD_KEY != NUMBER_NEW_KEY
|
||||
assert TEXT_OLD_KEY != TEXT_NEW_KEY
|
||||
|
||||
# Write and compile once
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
|
||||
# Release the reserved port so the binary can bind to it
|
||||
port_socket.close()
|
||||
|
||||
async def boot_and_get_initial_states() -> tuple[
|
||||
SwitchState, NumberState, TextState
|
||||
]:
|
||||
"""Boot the binary and return the restored entity states."""
|
||||
async with (
|
||||
run_binary_and_wait_for_port(binary_path, "127.0.0.1", port),
|
||||
wait_and_connect_api_client(port=port) as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info.name == DEVICE_NAME
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
switch_entity = require_entity(
|
||||
entities, "test_switch", SwitchInfo, "Test Switch"
|
||||
)
|
||||
number_entity = require_entity(
|
||||
entities, "test_number", NumberInfo, "Test Number"
|
||||
)
|
||||
text_entity = require_entity(entities, "test_text", TextInfo, "Test Text")
|
||||
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(
|
||||
initial_state_helper.on_state_wrapper(lambda s: None)
|
||||
)
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
switch_state = initial_state_helper.initial_states[switch_entity.key]
|
||||
number_state = initial_state_helper.initial_states[number_entity.key]
|
||||
text_state = initial_state_helper.initial_states[text_entity.key]
|
||||
assert isinstance(switch_state, SwitchState)
|
||||
assert isinstance(number_state, NumberState)
|
||||
assert isinstance(text_state, TextState)
|
||||
return switch_state, number_state, text_state
|
||||
|
||||
try:
|
||||
# --- Run 1: only OLD keys present, as written by pre-migration firmware.
|
||||
# The restored states prove the data was migrated to the new keys.
|
||||
write_host_prefs(
|
||||
DEVICE_NAME,
|
||||
{
|
||||
SWITCH_OLD_KEY: b"\x01", # bool: switch was ON
|
||||
NUMBER_OLD_KEY: struct.pack("<f", 42.5),
|
||||
TEXT_OLD_KEY: text_pref_payload("hello"),
|
||||
},
|
||||
)
|
||||
switch_state, number_state, text_state = await boot_and_get_initial_states()
|
||||
assert switch_state.state is True, (
|
||||
"Switch state stored under the old preference key was lost"
|
||||
)
|
||||
assert number_state.state == 42.5, (
|
||||
"Number value stored under the old preference key was lost"
|
||||
)
|
||||
assert text_state.state == "hello", (
|
||||
"Text value stored under the old preference key was lost"
|
||||
)
|
||||
|
||||
# --- Run 2: both keys present with different values. The NEW key holds
|
||||
# the current data and must win; stale old-key data must never clobber it.
|
||||
write_host_prefs(
|
||||
DEVICE_NAME,
|
||||
{
|
||||
SWITCH_OLD_KEY: b"\x00", # stale: OFF
|
||||
SWITCH_NEW_KEY: b"\x01", # current: ON
|
||||
NUMBER_OLD_KEY: struct.pack("<f", 42.5), # stale
|
||||
NUMBER_NEW_KEY: struct.pack("<f", 13.5), # current
|
||||
TEXT_OLD_KEY: text_pref_payload("hello"), # stale
|
||||
TEXT_NEW_KEY: text_pref_payload("world"), # current
|
||||
},
|
||||
)
|
||||
switch_state, number_state, text_state = await boot_and_get_initial_states()
|
||||
assert switch_state.state is True, (
|
||||
"Stale old-key data overwrote the current new-key switch state"
|
||||
)
|
||||
assert number_state.state == 13.5, (
|
||||
"Stale old-key data overwrote the current new-key number value"
|
||||
)
|
||||
assert text_state.state == "world", (
|
||||
"Stale old-key data overwrote the current new-key text value"
|
||||
)
|
||||
finally:
|
||||
clear_host_prefs(DEVICE_NAME)
|
||||
Reference in New Issue
Block a user