Files
core/homeassistant/components/knx/entity.py
T

220 lines
8.0 KiB
Python

"""Base classes for KNX entities."""
from dataclasses import dataclass
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.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import (
EntityPlatform,
async_get_current_platform,
)
from homeassistant.helpers.entity_registry import RegistryEntry
from .const import CONF_DEFAULT_ENTITY_ID, DOMAIN
from .storage.config_store import PlatformControllerBase
from .storage.const import CONF_DEVICE_INFO
if TYPE_CHECKING:
from .knx_module import KNXModule
def _stable_group_address_repr(part: DeviceGroupAddress | int | str | None) -> str:
"""Render a unique_id part independent of `GroupAddress.address_format`."""
if isinstance(part, GroupAddress):
# Always LONG (main/middle/sub) derived from raw, so the representation
# does not change when the global address format changes (e.g. on ETS
# project import). This is bijective with raw, keeping ids unique.
return (
f"{(part.raw >> 11) & 0b11111}/{(part.raw >> 8) & 0b111}/{part.raw & 0xFF}"
)
# InternalGroupAddress is already format-independent; None renders as "None"
return str(part)
def build_yaml_unique_id(
*parts: DeviceGroupAddress | int | str | None,
) -> tuple[str, str]:
"""Return `(new_stable_id, legacy_id)` for a YAML entity.
`new_stable_id` is independent of the global group address format. `legacy_id`
matches the id produced before this fix (using the current global format) and is
used to migrate registry entries of installations not using the 3-level style.
Pass the result as `unique_id` to `KnxYamlEntity`, which runs the migration.
"""
new_id = "_".join(_stable_group_address_repr(part) for part in parts)
legacy_id = "_".join(str(part) for part in parts)
return new_id, legacy_id
@callback
def async_migrate_yaml_unique_id(
hass: HomeAssistant, platform: str, legacy_id: str, new_id: str
) -> None:
"""Migrate a YAML entity unique_id from the legacy format to the stable one."""
# migration from unstable group address string parts added in 2026.8
if legacy_id == new_id:
return
ent_reg = er.async_get(hass)
if (entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, legacy_id)) is None:
return
try:
ent_reg.async_update_entity(entity_id, new_unique_id=new_id)
except ValueError:
# A stable-id entity already exists next to the legacy one - e.g. the
# original entity was orphaned under the stable id when the pre-fix bug
# registered the legacy entry. Keep the stable entry, drop the legacy one.
ent_reg.async_remove(entity_id)
@dataclass(slots=True, frozen=True)
class KnxEntityIdentifier:
"""Class to identify KNX entities in KNX frontend."""
platform: str
unique_id: str
ui: bool # ui or yaml entity
class KnxUiEntityPlatformController(PlatformControllerBase):
"""Class to manage dynamic adding and reloading of UI entities."""
def __init__(
self,
knx_module: KNXModule,
entity_platform: EntityPlatform,
entity_class: type[KnxUiEntity],
) -> None:
"""Initialize the UI platform."""
self._knx_module = knx_module
self._entity_platform = entity_platform
self._entity_class = entity_class
@override
async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None:
"""Add a new UI entity."""
await self._entity_platform.async_add_entities(
[self._entity_class(self._knx_module, unique_id, config)]
)
@override
async def update_entity(
self, entity_entry: RegistryEntry, config: dict[str, Any]
) -> None:
"""Update an existing UI entities configuration."""
await self._entity_platform.async_remove_entity(entity_entry.entity_id)
await self.create_entity(unique_id=entity_entry.unique_id, config=config)
class _KnxEntityBase(Entity):
"""Representation of a KNX entity."""
_attr_should_poll = False
_attr_unique_id: str
_knx_module: KNXModule
_device: XknxDevice
_knx_entity_identifier: KnxEntityIdentifier | None = None
@property
@override
def available(self) -> bool:
"""Return True if entity is available."""
return self._knx_module.connected
async def async_update(self) -> None:
"""Request a state update from KNX bus."""
await self._device.sync()
def after_update_callback(self, device: XknxDevice) -> None:
"""Call after device was updated."""
self.async_write_ha_state()
@override
async def async_added_to_hass(self) -> None:
"""Store register state change callback and start device object."""
self._device.register_device_updated_cb(self.after_update_callback)
self._device.xknx.devices.async_add(self._device)
if uid := self.unique_id:
self._knx_entity_identifier = KnxEntityIdentifier(
platform=self.platform_data.domain,
unique_id=uid,
ui=isinstance(self, KnxUiEntity),
)
self._knx_module.add_to_group_address_entities(
group_addresses=self._device.group_addresses(),
identifier=self._knx_entity_identifier,
)
# super call needed to have methods of multi-inherited classes called
# eg. for restoring state (like _KNXSwitch)
await super().async_added_to_hass()
@override
async def async_will_remove_from_hass(self) -> None:
"""Disconnect device object when removed."""
self._device.unregister_device_updated_cb(self.after_update_callback)
self._device.xknx.devices.async_remove(self._device)
if self._knx_entity_identifier:
self._knx_module.remove_from_group_address_entities(
group_addresses=self._device.group_addresses(),
identifier=self._knx_entity_identifier,
)
class KnxYamlEntity(_KnxEntityBase):
"""Representation of a KNX entity configured from YAML."""
def __init__(
self,
knx_module: KNXModule,
unique_id: tuple[str, str], # new_stable_id, legacy_id for migration
entity_config: dict[str, Any],
) -> None:
"""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.
"""
new_unique_id, legacy_unique_id = unique_id
async_migrate_yaml_unique_id(
knx_module.hass,
async_get_current_platform().domain,
legacy_unique_id,
new_unique_id,
)
self._knx_module = knx_module
self._attr_name = entity_config[CONF_NAME] or None
self._attr_unique_id = new_unique_id
self._attr_entity_category = entity_config.get(CONF_ENTITY_CATEGORY)
default_entity_id: str | None
if (default_entity_id := entity_config.get(CONF_DEFAULT_ENTITY_ID)) is not None:
self.entity_id = default_entity_id
class KnxUiEntity(_KnxEntityBase):
"""Representation of a KNX UI entity."""
_attr_has_entity_name = True
def __init__(
self, knx_module: KNXModule, unique_id: str, entity_config: dict[str, Any]
) -> None:
"""Initialize the UI entity."""
self._knx_module = knx_module
self._attr_name = entity_config[CONF_NAME]
self._attr_unique_id = unique_id
if entity_category := entity_config.get(CONF_ENTITY_CATEGORY):
self._attr_entity_category = EntityCategory(entity_category)
if device_info := entity_config.get(CONF_DEVICE_INFO):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})