diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index ff0526490f5..d17815d2344 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import network +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady from homeassistant.const import ( CONF_HOST, @@ -19,7 +20,11 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -158,12 +163,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SmartDeviceException as ex: raise ConfigEntryNotReady from ex + if device.is_dimmer: + async_fix_dimmer_unique_id(hass, entry, device) + hass.data[DOMAIN][entry.entry_id] = TPLinkDataUpdateCoordinator(hass, device) hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True +@callback +def async_fix_dimmer_unique_id( + hass: HomeAssistant, entry: ConfigEntry, device: SmartDevice +) -> None: + """Migrate the unique id of dimmers back to the legacy one. + + Dimmers used to use the switch format since pyHS100 treated them as SmartPlug but + the old code created them as lights + + https://github.com/home-assistant/core/blob/2021.9.7/homeassistant/components/tplink/common.py#L86 + """ + + # This is the unique id before 2021.0/2021.1 + original_unique_id = legacy_device_id(device) + + # This is the unique id that was used in 2021.0/2021.1 rollout + rollout_unique_id = device.mac.replace(":", "").upper() + + entity_registry = er.async_get(hass) + + rollout_entity_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, rollout_unique_id + ) + original_entry_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, original_unique_id + ) + + # If they are now using the 2021.0/2021.1 rollout entity id + # and have deleted the original entity id, we want to update that entity id + # so they don't end up with another _2 entity, but only if they deleted + # the original + if rollout_entity_id and not original_entry_id: + entity_registry.async_update_entity( + rollout_entity_id, new_unique_id=original_unique_id + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" hass_data: dict[str, Any] = hass.data[DOMAIN] diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 3f4b130a5cc..ad423e84fa5 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -26,6 +26,7 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, ) +from . import legacy_device_id from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity, async_refresh_after @@ -58,7 +59,14 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Initialize the switch.""" super().__init__(device, coordinator) # For backwards compat with pyHS100 - self._attr_unique_id = self.device.mac.replace(":", "").upper() + if self.device.is_dimmer: + # Dimmers used to use the switch format since + # pyHS100 treated them as SmartPlug but the old code + # created them as lights + # https://github.com/home-assistant/core/blob/2021.9.7/homeassistant/components/tplink/common.py#L86 + self._attr_unique_id = legacy_device_id(device) + else: + self._attr_unique_id = self.device.mac.replace(":", "").upper() @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index f25fc13784a..4e6dbb9dae7 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -from kasa import SmartBulb, SmartPlug, SmartStrip +from kasa import SmartBulb, SmartDimmer, SmartPlug, SmartStrip from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol @@ -48,6 +48,33 @@ def _mocked_bulb() -> SmartBulb: return bulb +def _mocked_dimmer() -> SmartDimmer: + dimmer = MagicMock(auto_spec=SmartDimmer) + dimmer.update = AsyncMock() + dimmer.mac = MAC_ADDRESS + dimmer.alias = ALIAS + dimmer.model = MODEL + dimmer.host = IP_ADDRESS + dimmer.brightness = 50 + dimmer.color_temp = 4000 + dimmer.is_color = True + dimmer.is_strip = False + dimmer.is_plug = False + dimmer.is_dimmer = True + dimmer.hsv = (10, 30, 5) + dimmer.device_id = MAC_ADDRESS + dimmer.valid_temperature_range.min = 4000 + dimmer.valid_temperature_range.max = 9000 + dimmer.hw_info = {"sw_ver": "1.0.0"} + dimmer.turn_off = AsyncMock() + dimmer.turn_on = AsyncMock() + dimmer.set_brightness = AsyncMock() + dimmer.set_hsv = AsyncMock() + dimmer.set_color_temp = AsyncMock() + dimmer.protocol = _mock_protocol() + return dimmer + + def _mocked_plug() -> SmartPlug: plug = MagicMock(auto_spec=SmartPlug) plug.update = AsyncMock() diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index c166fccc9b5..73edc63e28c 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -4,14 +4,23 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import MagicMock, patch +from homeassistant import setup from homeassistant.components import tplink from homeassistant.components.tplink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import IP_ADDRESS, MAC_ADDRESS, _patch_discovery, _patch_single_discovery +from . import ( + IP_ADDRESS, + MAC_ADDRESS, + _mocked_dimmer, + _patch_discovery, + _patch_single_discovery, +) from tests.common import MockConfigEntry, async_fire_time_changed @@ -63,3 +72,73 @@ async def test_config_entry_retry(hass): await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_dimmer_switch_unique_id_fix_original_entity_was_deleted( + hass: HomeAssistant, entity_reg: EntityRegistry +): + """Test that roll out unique id entity id changed to the original unique id.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=MAC_ADDRESS) + config_entry.add_to_hass(hass) + dimmer = _mocked_dimmer() + rollout_unique_id = MAC_ADDRESS.replace(":", "").upper() + original_unique_id = tplink.legacy_device_id(dimmer) + rollout_dimmer_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=rollout_unique_id, + original_name="Rollout dimmer", + ) + + with _patch_discovery(device=dimmer), _patch_single_discovery(device=dimmer): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + migrated_dimmer_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=original_unique_id, + original_name="Migrated dimmer", + ) + assert migrated_dimmer_entity_reg.entity_id == rollout_dimmer_entity_reg.entity_id + + +async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( + hass: HomeAssistant, entity_reg: EntityRegistry +): + """Test no migration happens if the original entity id still exists.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=MAC_ADDRESS) + config_entry.add_to_hass(hass) + dimmer = _mocked_dimmer() + rollout_unique_id = MAC_ADDRESS.replace(":", "").upper() + original_unique_id = tplink.legacy_device_id(dimmer) + original_dimmer_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=original_unique_id, + original_name="Original dimmer", + ) + rollout_dimmer_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=rollout_unique_id, + original_name="Rollout dimmer", + ) + + with _patch_discovery(device=dimmer), _patch_single_discovery(device=dimmer): + await setup.async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + migrated_dimmer_entity_reg = entity_reg.async_get_or_create( + config_entry=config_entry, + platform=DOMAIN, + domain="light", + unique_id=original_unique_id, + original_name="Migrated dimmer", + ) + assert migrated_dimmer_entity_reg.entity_id == original_dimmer_entity_reg.entity_id + assert migrated_dimmer_entity_reg.entity_id != rollout_dimmer_entity_reg.entity_id