diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 666ffad836f..daf69371c53 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -2,16 +2,19 @@ from __future__ import annotations +import logging import math from typing import Any from propcache.api import cached_property from xknx.devices import Fan as XknxFan +from xknx.telegram.address import parse_device_group_address from homeassistant import config_entries from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, async_get_current_platform, @@ -37,6 +40,58 @@ from .storage.const import ( ) from .storage.util import ConfigExtractor +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_migrate_yaml_uids( + hass: HomeAssistant, platform_config: list[ConfigType] +) -> None: + """Migrate entities unique_id for YAML switch-only fan entities.""" + # issue was introduced in 2026.1 - this migration in 2026.2 + ent_reg = er.async_get(hass) + invalid_uid = str(None) + if ( + none_entity_id := ent_reg.async_get_entity_id(Platform.FAN, DOMAIN, invalid_uid) + ) is None: + return + for config in platform_config: + if not config.get(KNX_ADDRESS) and ( + new_uid_base := config.get(FanSchema.CONF_SWITCH_ADDRESS) + ): + break + else: + _LOGGER.info( + "No YAML entry found to migrate fan entity '%s' unique_id from '%s'. Removing entry", + none_entity_id, + invalid_uid, + ) + ent_reg.async_remove(none_entity_id) + return + new_uid = str( + parse_device_group_address( + new_uid_base[0], # list of group addresses - first item is sending address + ) + ) + try: + ent_reg.async_update_entity(none_entity_id, new_unique_id=str(new_uid)) + _LOGGER.info( + "Migrating fan entity '%s' unique_id from '%s' to %s", + none_entity_id, + invalid_uid, + new_uid, + ) + except ValueError: + # New unique_id already exists - remove invalid entry. User might have changed YAML + _LOGGER.info( + "Failed to migrate fan entity '%s' unique_id from '%s' to '%s'. " + "Removing the invalid entry", + none_entity_id, + invalid_uid, + new_uid, + ) + ent_reg.async_remove(none_entity_id) + async def async_setup_entry( hass: HomeAssistant, @@ -57,6 +112,7 @@ async def async_setup_entry( entities: list[_KnxFan] = [] if yaml_platform_config := knx_module.config_yaml.get(Platform.FAN): + async_migrate_yaml_uids(hass, yaml_platform_config) entities.extend( KnxYamlFan(knx_module, entity_config) for entity_config in yaml_platform_config @@ -177,7 +233,10 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity): self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_unique_id = str(self._device.speed.group_address) + if self._device.speed.group_address: + self._attr_unique_id = str(self._device.speed.group_address) + else: + self._attr_unique_id = str(self._device.switch.group_address) class KnxUiFan(_KnxFan, KnxUiEntity): diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py index 7e04f3f5192..f6847b57fea 100644 --- a/tests/components/knx/test_fan.py +++ b/tests/components/knx/test_fan.py @@ -4,16 +4,19 @@ from typing import Any import pytest -from homeassistant.components.knx.const import KNX_ADDRESS, FanConf +from homeassistant.components.knx.const import DOMAIN, KNX_ADDRESS, FanConf from homeassistant.components.knx.schema import FanSchema from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import KnxEntityGenerator from .conftest import KNXTestKit -async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_fan_percent( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: """Test KNX fan with percentage speed.""" await knx.setup_integration( { @@ -23,6 +26,9 @@ async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) + entry = entity_registry.async_get("fan.test") + assert entry + assert entry.unique_id == "1/2/3" # turn on fan with default speed (50%) await hass.services.async_call( @@ -109,7 +115,9 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_telegram_count(0) -async def test_fan_switch(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_fan_switch( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: """Test KNX fan with switch only.""" await knx.setup_integration( { @@ -119,6 +127,9 @@ async def test_fan_switch(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) + entry = entity_registry.async_get("fan.test") + assert entry + assert entry.unique_id == "1/2/3" # turn on fan await hass.services.async_call( @@ -133,7 +144,9 @@ async def test_fan_switch(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/2/3", False) -async def test_fan_switch_step(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_fan_switch_step( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: """Test KNX fan with speed steps and switch address.""" await knx.setup_integration( { @@ -145,6 +158,9 @@ async def test_fan_switch_step(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) + entry = entity_registry.async_get("fan.test") + assert entry + assert entry.unique_id == "1/1/1" # turn on fan without percentage - actuator sets default speed await hass.services.async_call( @@ -216,6 +232,53 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None: assert state.attributes.get("oscillating") is False +@pytest.mark.parametrize( + "fan_config", + [ + { + # before fix: unique_id is 'None', after fix: from switch address + CONF_NAME: "test", + FanSchema.CONF_SWITCH_ADDRESS: "1/2/3", + }, + { + # no change in unique_id here, but since no YAML to update from, the + # invalid registry entry will be removed - it wouldn't be loaded anyway + # this YAML will create a new entry (same unique_id as above for tests) + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + }, + ], +) +async def test_fan_unique_id_fix( + hass: HomeAssistant, + knx: KNXTestKit, + entity_registry: er.EntityRegistry, + fan_config: dict[str, Any], +) -> None: + """Test KNX fan unique_id migration fix.""" + invalid_unique_id = "None" + knx.mock_config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + object_id_base="test", + disabled_by=None, + domain=Platform.FAN, + platform=DOMAIN, + unique_id=invalid_unique_id, + config_entry=knx.mock_config_entry, + ) + await knx.setup_integration( + {FanSchema.PLATFORM: fan_config}, + add_entry_to_hass=False, + ) + entry = entity_registry.async_get("fan.test") + assert entry + assert entry.unique_id == "1/2/3" + # Verify the old entity with invalid unique_id has been updated or removed + assert not entity_registry.async_get_entity_id( + Platform.FAN, DOMAIN, invalid_unique_id + ) + + @pytest.mark.parametrize( ("knx_data", "expected_read_response", "expected_state"), [