Add user-defined unique_id to KNX YAML entities

Allow assigning a manual `unique_id` to any KNX YAML entity via a new optional
config key. When the key is added to an existing entity, its auto-generated
registry entry is migrated to the user-defined id so history, area and
customizations are preserved. Removing the key again cannot be migrated back
(documented).

The key is added once to the shared entity base schema, so all YAML platforms
support it. Duplicate user ids are rejected within a platform; the same id on
different platforms is allowed, matching the entity registry's per-domain
uniqueness scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
farmio
2026-07-27 16:49:04 +02:00
parent d597c9bb6f
commit 208e5aea5d
3 changed files with 160 additions and 9 deletions
+17 -6
View File
@@ -6,7 +6,12 @@ from typing import TYPE_CHECKING, Any, override
from xknx.devices import Device as XknxDevice
from xknx.telegram.address import DeviceGroupAddress, GroupAddress
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, EntityCategory
from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_NAME,
CONF_UNIQUE_ID,
EntityCategory,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
@@ -181,15 +186,21 @@ class KnxYamlEntity(_KnxEntityBase):
"""Initialize the YAML entity.
`unique_id` is the `(new_stable_id, legacy_id)` tuple from
`build_yaml_unique_id`; the legacy id is migrated to the stable one.
`build_yaml_unique_id`; the legacy id is migrated to the stable one. A
user-defined `unique_id` in the config takes precedence, and an existing
auto-generated entity is migrated to it so history is preserved. Removing
a user-defined `unique_id` again cannot be migrated back.
"""
new_unique_id, legacy_unique_id = unique_id
platform = async_get_current_platform().domain
async_migrate_yaml_unique_id(
knx_module.hass,
async_get_current_platform().domain,
legacy_unique_id,
new_unique_id,
knx_module.hass, platform, legacy_unique_id, new_unique_id
)
if user_unique_id := entity_config.get(CONF_UNIQUE_ID):
async_migrate_yaml_unique_id(
knx_module.hass, platform, new_unique_id, user_unique_id
)
new_unique_id = user_unique_id
self._knx_module = knx_module
self._attr_name = entity_config[CONF_NAME] or None
self._attr_unique_id = new_unique_id
+19 -1
View File
@@ -40,6 +40,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_PAYLOAD,
CONF_TYPE,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
Platform,
@@ -188,6 +189,22 @@ class EventSchema:
#############
def _unique_id_duplicate_validator(entities: list[dict]) -> list[dict]:
"""Validate that user-defined unique_ids are unique within a platform.
The same unique_id on different platforms is allowed - the entity registry
scopes uniqueness per (entity domain, integration).
"""
seen: set[str] = set()
for entity in entities:
if (unique_id := entity.get(CONF_UNIQUE_ID)) is None:
continue
if unique_id in seen:
raise vol.Invalid(f"duplicate 'unique_id' not allowed: {unique_id}")
seen.add(unique_id)
return entities
class KNXPlatformSchema(ABC):
"""Voluptuous schema for KNX platform entity configuration."""
@@ -199,7 +216,7 @@ class KNXPlatformSchema(ABC):
"""Return a schema node for the platform."""
return {
vol.Optional(str(cls.PLATFORM)): vol.All(
cv.ensure_list, [cls.ENTITY_SCHEMA]
cv.ensure_list, [cls.ENTITY_SCHEMA], _unique_id_duplicate_validator
)
}
@@ -213,6 +230,7 @@ def _entity_base_schema(platform: Platform) -> vol.Schema:
cv.entity_id, cv.entity_domain(platform)
),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_UNIQUE_ID): vol.All(cv.string, vol.Length(min=1)),
}
)
+124 -2
View File
@@ -8,8 +8,8 @@ from xknx.telegram.address import GroupAddress, GroupAddressType, InternalGroupA
from homeassistant.components.knx.const import DOMAIN, KNX_ADDRESS
from homeassistant.components.knx.entity import build_yaml_unique_id
from homeassistant.components.knx.schema import SwitchSchema
from homeassistant.const import CONF_NAME, Platform
from homeassistant.components.knx.schema import LightSchema, SwitchSchema
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -117,3 +117,125 @@ async def test_yaml_unique_id_migration_collision(
Platform.SWITCH, DOMAIN, legacy_unique_id
)
assert entity_registry.async_get(legacy_entry.entity_id) is None
async def test_yaml_user_unique_id(
hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry
) -> None:
"""A user-defined unique_id is used verbatim instead of the derived one."""
await knx.setup_integration(
{
SwitchSchema.PLATFORM: {
CONF_NAME: "test",
KNX_ADDRESS: "1/2/3",
CONF_UNIQUE_ID: "my_switch",
}
}
)
entry = entity_registry.async_get("switch.test")
assert entry
assert entry.unique_id == "my_switch"
async def test_yaml_user_unique_id_migration(
hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry
) -> None:
"""Adding a user-defined unique_id migrates the auto-generated entity to it."""
auto_unique_id = "1/2/3"
knx.mock_config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
object_id_base="test",
domain=Platform.SWITCH,
platform=DOMAIN,
unique_id=auto_unique_id,
config_entry=knx.mock_config_entry,
)
await knx.setup_integration(
{
SwitchSchema.PLATFORM: {
CONF_NAME: "test",
KNX_ADDRESS: "1/2/3",
CONF_UNIQUE_ID: "my_switch",
}
},
add_entry_to_hass=False,
)
entry = entity_registry.async_get("switch.test")
assert entry
assert entry.unique_id == "my_switch"
assert not entity_registry.async_get_entity_id(
Platform.SWITCH, DOMAIN, auto_unique_id
)
@pytest.mark.usefixtures("two_level_address_format")
async def test_yaml_user_unique_id_migration_from_legacy(
hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry
) -> None:
"""A user-defined unique_id migrates even from the legacy (2-level) id."""
legacy_unique_id = "1/515"
knx.mock_config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
object_id_base="test",
domain=Platform.SWITCH,
platform=DOMAIN,
unique_id=legacy_unique_id,
config_entry=knx.mock_config_entry,
)
await knx.setup_integration(
{
SwitchSchema.PLATFORM: {
CONF_NAME: "test",
KNX_ADDRESS: "1/2/3",
CONF_UNIQUE_ID: "my_switch",
}
},
add_entry_to_hass=False,
)
entry = entity_registry.async_get("switch.test")
assert entry
assert entry.unique_id == "my_switch"
assert not entity_registry.async_get_entity_id(
Platform.SWITCH, DOMAIN, legacy_unique_id
)
assert not entity_registry.async_get_entity_id(Platform.SWITCH, DOMAIN, "1/2/3")
async def test_yaml_user_unique_id_shared_across_platforms(
hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry
) -> None:
"""The same user unique_id is allowed on entities of different platforms."""
await knx.setup_integration(
{
SwitchSchema.PLATFORM: {
CONF_NAME: "sw",
KNX_ADDRESS: "1/2/3",
CONF_UNIQUE_ID: "shared",
},
LightSchema.PLATFORM: {
CONF_NAME: "li",
KNX_ADDRESS: "1/2/4",
CONF_UNIQUE_ID: "shared",
},
}
)
switch_entry = entity_registry.async_get("switch.sw")
light_entry = entity_registry.async_get("light.li")
assert switch_entry and switch_entry.unique_id == "shared"
assert light_entry and light_entry.unique_id == "shared"
async def test_yaml_duplicate_user_unique_id_invalid(
hass: HomeAssistant, knx: KNXTestKit, caplog: pytest.LogCaptureFixture
) -> None:
"""Two entities of the same platform must not share a user unique_id."""
await knx.setup_integration(
{
SwitchSchema.PLATFORM: [
{CONF_NAME: "a", KNX_ADDRESS: "1/2/3", CONF_UNIQUE_ID: "dup"},
{CONF_NAME: "b", KNX_ADDRESS: "1/2/4", CONF_UNIQUE_ID: "dup"},
]
}
)
assert "duplicate 'unique_id' not allowed: dup" in caplog.text
assert hass.states.get("switch.a") is None