mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
240 lines
9.8 KiB
Python
240 lines
9.8 KiB
Python
"""Tests to verify preference and entity key hash values remain stable.
|
|
|
|
These tests ensure the hash algorithms do NOT change, as any change would cause
|
|
users to lose stored preferences (calibration values, restore states, etc.) on
|
|
firmware upgrades, or break entity state routing to API clients.
|
|
|
|
Two algorithms are locked here (see https://github.com/esphome/backlog/issues/85):
|
|
1. `fnv1_hash_object_id(name)` - the LEGACY hash (snake_case + sanitize, then FNV-1).
|
|
Existing devices have preferences stored under keys derived from it; slot-based
|
|
backends (ESP8266, RP2040) keep using it, and key-lookup backends migrate FROM it.
|
|
2. `fnv1_hash_name(name)` - the entity key (FNV-1 over the raw UTF-8 name bytes).
|
|
Sent to API clients and used as the preference key base on key-lookup backends.
|
|
|
|
DO NOT CHANGE THE EXPECTED VALUES - if tests fail after modifying a hash algorithm,
|
|
the change breaks backward compatibility and will cause data loss.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from esphome.helpers import (
|
|
FNV1_OFFSET_BASIS,
|
|
FNV1_PRIME,
|
|
fnv1_hash_name,
|
|
fnv1_hash_object_id,
|
|
)
|
|
|
|
# =============================================================================
|
|
# Test: fnv1_hash_object_id produces stable hashes for entity names
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("entity_name", "expected_object_id_hash"),
|
|
[
|
|
# =====================================================================
|
|
# Core entity types - these names appear in many ESPHome configurations
|
|
# =====================================================================
|
|
# Basic single-word names
|
|
("Light", 0x735CF023),
|
|
("Switch", 0xBEDF78E5),
|
|
("Sensor", 0x75E61B1B),
|
|
("Fan", 0x468F6780),
|
|
("Climate", 0xAA22FD4A),
|
|
("Cover", 0xA630D0A2),
|
|
("Lock", 0x1D2FD708),
|
|
("Valve", 0x25ED5F65),
|
|
("Button", 0x3A42C455),
|
|
("Number", 0xB900E22A),
|
|
("Select", 0x556391B5),
|
|
("Text", 0xB12BFA38),
|
|
# Multi-word names (spaces become underscores, lowercase)
|
|
("Living Room Light", 0xC6F81EC9),
|
|
("Kitchen Switch", 0xC63C0F6E),
|
|
("Temperature Sensor", 0x16AF55B6),
|
|
("Garage Door Cover", 0x685E5281),
|
|
("Bedroom Fan", 0x21AB1DED),
|
|
("Front Door Lock", 0xB9BEF8E1),
|
|
# Already snake_case names (should hash same as space-separated)
|
|
("living_room_light", 0xC6F81EC9), # Same as "Living Room Light"
|
|
("kitchen_switch", 0xC63C0F6E), # Same as "Kitchen Switch"
|
|
# Names with numbers
|
|
("Sensor 1", 0x99828E4B),
|
|
("Relay 2", 0x6FFEF2FB),
|
|
("Zone 10", 0xFD83AA95),
|
|
# Names with special characters (become underscores)
|
|
("AC Unit", 0x336C6886),
|
|
("WiFi Signal", 0x2FA52175),
|
|
("CO2 Level", 0x31049870),
|
|
# Mixed case handling
|
|
("mySwitch", 0x9AA10553),
|
|
("MySwitch", 0x9AA10553), # Same as lowercase
|
|
("MYSWITCH", 0x9AA10553), # Same as lowercase
|
|
# =====================================================================
|
|
# Edge cases
|
|
# =====================================================================
|
|
# Empty name (hashes to the FNV-1 offset basis since no chars processed)
|
|
("", 0x811C9DC5),
|
|
# Single character
|
|
("a", 0x050C5D7E),
|
|
("A", 0x050C5D7E), # Same after lowercase
|
|
("1", 0x050C5D2E),
|
|
("_", 0x050C5D40),
|
|
# Names that differ only in case (should hash identically)
|
|
("test", 0xBC2C0BE9),
|
|
("Test", 0xBC2C0BE9),
|
|
("TEST", 0xBC2C0BE9),
|
|
# Names that differ only in spaces vs underscores (should hash identically)
|
|
("foo bar", 0x3AE35AA1),
|
|
("foo_bar", 0x3AE35AA1),
|
|
("Foo Bar", 0x3AE35AA1),
|
|
("FOO_BAR", 0x3AE35AA1),
|
|
# Non-ASCII names (sanitized per code point, one underscore per character)
|
|
("äöü", 0x10028B12),
|
|
("温度", 0x3276CB9F),
|
|
("Température", 0x965698F3),
|
|
# =====================================================================
|
|
# Real-world component entity names from ESPHome codebase
|
|
# =====================================================================
|
|
# From fan.cpp - FanRestoreState
|
|
("Ceiling Fan", 0x640DEF00),
|
|
# From climate.cpp - ClimateRestoreState
|
|
("HVAC", 0xDD68438B),
|
|
("Thermostat", 0x30A5B7C6),
|
|
# From light/light_state.cpp
|
|
("LED Strip", 0x2A068423),
|
|
("Dimmable Light", 0xD70393F3),
|
|
# From cover/cover.cpp
|
|
("Garage Door", 0x53987A5D),
|
|
("Window Blind", 0x851291A5),
|
|
# From switch/switch.cpp
|
|
("Relay", 0xD3A92FE4),
|
|
("Power Switch", 0x5C4A47B3),
|
|
# From number/automation.cpp
|
|
("Brightness", 0xF46E252C),
|
|
("Volume", 0x8FFEBE43),
|
|
# From template datetime entities
|
|
("Wake Time", 0xEE612B53),
|
|
("Schedule Date", 0xF538C8DD),
|
|
],
|
|
)
|
|
def test_entity_object_id_hash_stability(
|
|
entity_name: str, expected_object_id_hash: int
|
|
) -> None:
|
|
"""Verify fnv1_hash_object_id produces stable hashes for entity names.
|
|
|
|
CRITICAL: These expected values MUST NOT CHANGE. Existing devices have
|
|
preferences stored under keys derived from this legacy hash; changing it
|
|
breaks the old-to-new key migration and loses stored preferences.
|
|
"""
|
|
actual = fnv1_hash_object_id(entity_name)
|
|
assert actual == expected_object_id_hash, (
|
|
f"Hash for '{entity_name}' changed from {expected_object_id_hash:#010x} to {actual:#010x}. "
|
|
f"This will cause users to lose stored preferences!"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Test: Legacy preference key computation formula
|
|
# =============================================================================
|
|
|
|
|
|
def compute_legacy_preference_key(
|
|
entity_name: str, version: int = 0, device_id: int = 0
|
|
) -> int:
|
|
"""Compute the legacy preference key: (object_id_hash ^ device_id) ^ version.
|
|
|
|
This is the key existing devices have data stored under. Slot-based backends
|
|
(ESP8266, RP2040) still use it directly; key-lookup backends compute it as the
|
|
migration source in EntityBase::make_entity_preference_() (entity_base.cpp).
|
|
"""
|
|
object_id_hash = fnv1_hash_object_id(entity_name)
|
|
preference_hash = object_id_hash ^ device_id
|
|
key = preference_hash ^ version
|
|
return key & 0xFFFFFFFF
|
|
|
|
|
|
# Restore state version constants from ESPHome components
|
|
# These MUST match the RESTORE_STATE_VERSION values in the C++ code
|
|
FAN_RESTORE_STATE_VERSION = 0x71700ABA # From fan/fan.cpp
|
|
CLIMATE_RESTORE_STATE_VERSION = 0x848EA6AD # From climate/climate.cpp
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("entity_name", "version", "device_id", "expected_key"),
|
|
[
|
|
# No version, main device (key equals the plain object_id hash)
|
|
("Test Sensor", 0, 0, 0x5D74FA46),
|
|
("Light", 0, 0, 0x735CF023),
|
|
# Restore state versions on the main device
|
|
("Ceiling Fan", FAN_RESTORE_STATE_VERSION, 0, 0x157DE5BA),
|
|
("HVAC", CLIMATE_RESTORE_STATE_VERSION, 0, 0x59E6E526),
|
|
# Sub-devices: same entity name on different devices gets different keys
|
|
("Light", 0, 1, 0x735CF022),
|
|
("Fan", FAN_RESTORE_STATE_VERSION, 0xABCD, 0x37FFC6F7),
|
|
],
|
|
)
|
|
def test_legacy_preference_key_computation(
|
|
entity_name: str, version: int, device_id: int, expected_key: int
|
|
) -> None:
|
|
"""Verify legacy preference key computation matches expected values.
|
|
|
|
This test ensures the formula doesn't change, which would break both slot-based
|
|
preference storage and the migration source keys on key-lookup backends.
|
|
"""
|
|
actual_key = compute_legacy_preference_key(entity_name, version, device_id)
|
|
|
|
assert actual_key == expected_key, (
|
|
f"Preference key for '{entity_name}' (version={version:#x}, device_id={device_id}) "
|
|
f"changed from {expected_key:#010x} to {actual_key:#010x}. "
|
|
f"This will cause users to lose stored preferences!"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Test: fnv1_hash_name produces stable entity keys (raw name, UTF-8 bytes)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("entity_name", "expected_key"),
|
|
[
|
|
# ASCII names
|
|
("Temperature Sensor", 0x801C3665),
|
|
("LED Strip", 0xD5C7B082),
|
|
("Garage Door", 0x2D70E086),
|
|
("Relay", 0x565177C4),
|
|
# Raw names are case and space sensitive, unlike the old object_id hash
|
|
("temperature sensor", 0xF9F431E5),
|
|
# Non-ASCII names hash their UTF-8 bytes and stay distinct
|
|
("Датчик открытия", 0x001861C1),
|
|
("温度", 0x8EDF61C9),
|
|
("Température", 0x531A74AA),
|
|
# Empty name hashes to the FNV-1 offset basis
|
|
("", 0x811C9DC5),
|
|
],
|
|
)
|
|
def test_entity_key_hash_stability(entity_name: str, expected_key: int) -> None:
|
|
"""Verify fnv1_hash_name produces stable entity keys.
|
|
|
|
CRITICAL: These expected values MUST NOT CHANGE. The entity key is sent to
|
|
API clients and is the new preference key base; changing the algorithm
|
|
would break state routing and lose stored preferences.
|
|
Must match C++ fnv1_hash_bytes() in esphome/core/helpers.h.
|
|
"""
|
|
actual = fnv1_hash_name(entity_name)
|
|
assert actual == expected_key, (
|
|
f"Entity key for '{entity_name}' changed from {expected_key:#010x} to {actual:#010x}. "
|
|
f"This breaks state routing and stored preferences!"
|
|
)
|
|
|
|
|
|
def test_fnv1_hash_name_matches_utf8_byte_hash() -> None:
|
|
"""Verify fnv1_hash_name hashes the UTF-8 encoded bytes of the name."""
|
|
name = "Température 温度"
|
|
hash_value = FNV1_OFFSET_BASIS
|
|
for byte in name.encode("utf-8"):
|
|
hash_value = (hash_value * FNV1_PRIME) & 0xFFFFFFFF
|
|
hash_value ^= byte
|
|
assert fnv1_hash_name(name) == hash_value
|