From dc9133f919dfbe76fe874625cc18dd2078d373b3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 21 Dec 2024 13:26:09 +0100 Subject: [PATCH] Use mac address in Twinkly for unique id (#133717) --- homeassistant/components/twinkly/__init__.py | 42 +++++++++++++- .../components/twinkly/config_flow.py | 5 +- homeassistant/components/twinkly/light.py | 4 +- .../twinkly/snapshots/test_diagnostics.ambr | 4 +- tests/components/twinkly/test_init.py | 58 +++++++++++++++++-- tests/components/twinkly/test_light.py | 18 +++--- 6 files changed, 110 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index 00e40d604c0..cd76a79e1d7 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -1,6 +1,7 @@ """The twinkly component.""" from dataclasses import dataclass +import logging from typing import Any from aiohttp import ClientError @@ -10,12 +11,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ATTR_VERSION +from .const import ATTR_VERSION, DOMAIN PLATFORMS = [Platform.LIGHT] +_LOGGER = logging.getLogger(__name__) + @dataclass class TwinklyData: @@ -56,3 +60,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> """Remove a twinkly entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool: + """Migrate old entry.""" + if entry.minor_version == 1: + client = Twinkly(entry.data[CONF_HOST], async_get_clientsession(hass)) + try: + device_info = await client.get_details() + except (TimeoutError, ClientError) as exception: + _LOGGER.error("Error while migrating: %s", exception) + return False + identifier = entry.unique_id + assert identifier is not None + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id("light", DOMAIN, identifier) + if entity_id: + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + entity_registry.async_update_entity( + entity_entry.entity_id, new_unique_id=device_info["mac"] + ) + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, identifier)} + ) + if device_entry: + device_registry.async_update_device( + device_entry.id, new_identifiers={(DOMAIN, device_info["mac"])} + ) + hass.config_entries.async_update_entry( + entry, + unique_id=device_info["mac"], + minor_version=2, + ) + + return True diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index 837bd9ccb6a..4dec8809f07 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -23,6 +23,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle twinkly config flow.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -46,7 +47,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_HOST] = "cannot_connect" else: await self.async_set_unique_id( - device_info[DEV_ID], raise_on_progress=False + device_info["mac"], raise_on_progress=False ) self._abort_if_unique_id_configured() @@ -64,7 +65,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN): device_info = await Twinkly( discovery_info.ip, async_get_clientsession(self.hass) ).get_details() - await self.async_set_unique_id(device_info[DEV_ID]) + await self.async_set_unique_id(device_info["mac"]) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self._discovered_device = (device_info, discovery_info.ip) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index d05da7bab15..7de07db3b30 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -60,8 +60,8 @@ class TwinklyLight(LightEntity): entry: TwinklyConfigEntry, ) -> None: """Initialize a TwinklyLight entity.""" - self._attr_unique_id: str = entry.data[CONF_ID] device_info = entry.runtime_data.device_info + self._attr_unique_id: str = device_info["mac"] self._conf = entry if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW: @@ -98,7 +98,7 @@ class TwinklyLight(LightEntity): def device_info(self) -> DeviceInfo | None: """Get device specific attributes.""" return DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, + identifiers={(DOMAIN, self._mac)}, connections={(CONNECTION_NETWORK_MAC, self._mac)}, manufacturer="LEDWORKS", model=self._model, diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 4d25e222501..abd923dcb83 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -32,14 +32,14 @@ }), 'domain': 'twinkly', 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', 'title': 'Twinkly', - 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, }), 'sw_version': '2.8.10', diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index 6642807ac3f..60ebe65b445 100644 --- a/tests/components/twinkly/test_init.py +++ b/tests/components/twinkly/test_init.py @@ -1,14 +1,16 @@ -"""Tests of the initialization of the twinly integration.""" +"""Tests of the initialization of the twinkly integration.""" from unittest.mock import patch from uuid import uuid4 -from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.twinkly.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import TEST_HOST, TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock +from . import TEST_HOST, TEST_MAC, TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock from tests.common import MockConfigEntry @@ -19,7 +21,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: device_id = str(uuid4()) config_entry = MockConfigEntry( - domain=TWINKLY_DOMAIN, + domain=DOMAIN, data={ CONF_HOST: TEST_HOST, CONF_ID: device_id, @@ -27,6 +29,8 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: CONF_MODEL: TEST_MODEL, }, entry_id=device_id, + unique_id=TEST_MAC, + minor_version=2, ) config_entry.add_to_hass(hass) @@ -47,13 +51,15 @@ async def test_config_entry_not_ready(hass: HomeAssistant) -> None: client.is_offline = True config_entry = MockConfigEntry( - domain=TWINKLY_DOMAIN, + domain=DOMAIN, data={ CONF_HOST: TEST_HOST, CONF_ID: id, CONF_NAME: TEST_NAME_ORIGINAL, CONF_MODEL: TEST_MODEL, }, + minor_version=2, + unique_id=TEST_MAC, ) config_entry.add_to_hass(hass) @@ -62,3 +68,45 @@ async def test_config_entry_not_ready(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_mac_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Validate that the unique_id is migrated to the MAC address.""" + client = ClientMock() + + config_entry = MockConfigEntry( + domain=DOMAIN, + minor_version=1, + unique_id="unique_id", + data={ + CONF_HOST: TEST_HOST, + CONF_ID: id, + CONF_NAME: TEST_NAME_ORIGINAL, + CONF_MODEL: TEST_MODEL, + }, + ) + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + LIGHT_DOMAIN, + DOMAIN, + config_entry.unique_id, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, config_entry.unique_id)}, + ) + + with patch("homeassistant.components.twinkly.Twinkly", return_value=client): + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.LOADED + + assert entity_registry.async_get(entity_entry.entity_id).unique_id == TEST_MAC + assert device_registry.async_get_device( + identifiers={(DOMAIN, config_entry.unique_id)} + ).identifiers == {(DOMAIN, TEST_MAC)} + assert config_entry.unique_id == TEST_MAC diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index 7a55dbec14a..26df83aebe0 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -15,7 +15,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry -from . import TEST_MODEL, TEST_NAME, TEST_NAME_ORIGINAL, ClientMock +from . import TEST_MAC, TEST_MODEL, TEST_NAME, TEST_NAME_ORIGINAL, ClientMock from tests.common import MockConfigEntry, async_fire_time_changed @@ -301,7 +301,7 @@ async def test_update_name( async_fire_time_changed(hass) await hass.async_block_till_done() - dev_entry = device_registry.async_get_device({(TWINKLY_DOMAIN, client.id)}) + dev_entry = device_registry.async_get_device({(TWINKLY_DOMAIN, TEST_MAC)}) assert dev_entry.name == "new_device_name" assert config_entry.data[CONF_NAME] == "new_device_name" @@ -310,10 +310,9 @@ async def test_update_name( async def test_unload(hass: HomeAssistant) -> None: """Validate that entities can be unloaded from the UI.""" - _, _, client, _ = await _create_entries(hass) - entry_id = client.id + _, _, _, entry = await _create_entries(hass) - assert await hass.config_entries.async_unload(entry_id) + assert await hass.config_entries.async_unload(entry.entry_id) async def _create_entries( @@ -330,18 +329,19 @@ async def _create_entries( CONF_NAME: TEST_NAME_ORIGINAL, CONF_MODEL: TEST_MODEL, }, - entry_id=client.id, + unique_id=TEST_MAC, + minor_version=2, ) config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(client.id) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) - entity_id = entity_registry.async_get_entity_id("light", TWINKLY_DOMAIN, client.id) + entity_id = entity_registry.async_get_entity_id("light", TWINKLY_DOMAIN, TEST_MAC) entity_entry = entity_registry.async_get(entity_id) - device = device_registry.async_get_device(identifiers={(TWINKLY_DOMAIN, client.id)}) + device = device_registry.async_get_device(identifiers={(TWINKLY_DOMAIN, TEST_MAC)}) assert entity_entry is not None assert device is not None