Use mac address in Twinkly for unique id (#133717)

pull/133724/head
Joost Lekkerkerker 2024-12-21 13:26:09 +01:00 committed by GitHub
parent a3fad89d0d
commit dc9133f919
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 110 additions and 21 deletions

View File

@ -1,6 +1,7 @@
"""The twinkly component.""" """The twinkly component."""
from dataclasses import dataclass from dataclasses import dataclass
import logging
from typing import Any from typing import Any
from aiohttp import ClientError from aiohttp import ClientError
@ -10,12 +11,15 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady 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 homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ATTR_VERSION from .const import ATTR_VERSION, DOMAIN
PLATFORMS = [Platform.LIGHT] PLATFORMS = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
class TwinklyData: class TwinklyData:
@ -56,3 +60,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) ->
"""Remove a twinkly entry.""" """Remove a twinkly entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 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

View File

@ -23,6 +23,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle twinkly config flow.""" """Handle twinkly config flow."""
VERSION = 1 VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""
@ -46,7 +47,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN):
errors[CONF_HOST] = "cannot_connect" errors[CONF_HOST] = "cannot_connect"
else: else:
await self.async_set_unique_id( 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() self._abort_if_unique_id_configured()
@ -64,7 +65,7 @@ class TwinklyConfigFlow(ConfigFlow, domain=DOMAIN):
device_info = await Twinkly( device_info = await Twinkly(
discovery_info.ip, async_get_clientsession(self.hass) discovery_info.ip, async_get_clientsession(self.hass)
).get_details() ).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._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
self._discovered_device = (device_info, discovery_info.ip) self._discovered_device = (device_info, discovery_info.ip)

View File

@ -60,8 +60,8 @@ class TwinklyLight(LightEntity):
entry: TwinklyConfigEntry, entry: TwinklyConfigEntry,
) -> None: ) -> None:
"""Initialize a TwinklyLight entity.""" """Initialize a TwinklyLight entity."""
self._attr_unique_id: str = entry.data[CONF_ID]
device_info = entry.runtime_data.device_info device_info = entry.runtime_data.device_info
self._attr_unique_id: str = device_info["mac"]
self._conf = entry self._conf = entry
if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW: if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW:
@ -98,7 +98,7 @@ class TwinklyLight(LightEntity):
def device_info(self) -> DeviceInfo | None: def device_info(self) -> DeviceInfo | None:
"""Get device specific attributes.""" """Get device specific attributes."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)}, identifiers={(DOMAIN, self._mac)},
connections={(CONNECTION_NETWORK_MAC, self._mac)}, connections={(CONNECTION_NETWORK_MAC, self._mac)},
manufacturer="LEDWORKS", manufacturer="LEDWORKS",
model=self._model, model=self._model,

View File

@ -32,14 +32,14 @@
}), }),
'domain': 'twinkly', 'domain': 'twinkly',
'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2',
'minor_version': 1, 'minor_version': 2,
'options': dict({ 'options': dict({
}), }),
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'title': 'Twinkly', 'title': 'Twinkly',
'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'unique_id': 'aa:bb:cc:dd:ee:ff',
'version': 1, 'version': 1,
}), }),
'sw_version': '2.8.10', 'sw_version': '2.8.10',

View File

@ -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 unittest.mock import patch
from uuid import uuid4 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.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME
from homeassistant.core import HomeAssistant 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 from tests.common import MockConfigEntry
@ -19,7 +21,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None:
device_id = str(uuid4()) device_id = str(uuid4())
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=TWINKLY_DOMAIN, domain=DOMAIN,
data={ data={
CONF_HOST: TEST_HOST, CONF_HOST: TEST_HOST,
CONF_ID: device_id, CONF_ID: device_id,
@ -27,6 +29,8 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None:
CONF_MODEL: TEST_MODEL, CONF_MODEL: TEST_MODEL,
}, },
entry_id=device_id, entry_id=device_id,
unique_id=TEST_MAC,
minor_version=2,
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -47,13 +51,15 @@ async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
client.is_offline = True client.is_offline = True
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=TWINKLY_DOMAIN, domain=DOMAIN,
data={ data={
CONF_HOST: TEST_HOST, CONF_HOST: TEST_HOST,
CONF_ID: id, CONF_ID: id,
CONF_NAME: TEST_NAME_ORIGINAL, CONF_NAME: TEST_NAME_ORIGINAL,
CONF_MODEL: TEST_MODEL, CONF_MODEL: TEST_MODEL,
}, },
minor_version=2,
unique_id=TEST_MAC,
) )
config_entry.add_to_hass(hass) 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) await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.SETUP_RETRY 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

View File

@ -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.device_registry import DeviceEntry
from homeassistant.helpers.entity_registry import RegistryEntry 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 from tests.common import MockConfigEntry, async_fire_time_changed
@ -301,7 +301,7 @@ async def test_update_name(
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() 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 dev_entry.name == "new_device_name"
assert config_entry.data[CONF_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: async def test_unload(hass: HomeAssistant) -> None:
"""Validate that entities can be unloaded from the UI.""" """Validate that entities can be unloaded from the UI."""
_, _, client, _ = await _create_entries(hass) _, _, _, entry = await _create_entries(hass)
entry_id = client.id
assert await hass.config_entries.async_unload(entry_id) assert await hass.config_entries.async_unload(entry.entry_id)
async def _create_entries( async def _create_entries(
@ -330,18 +329,19 @@ async def _create_entries(
CONF_NAME: TEST_NAME_ORIGINAL, CONF_NAME: TEST_NAME_ORIGINAL,
CONF_MODEL: TEST_MODEL, CONF_MODEL: TEST_MODEL,
}, },
entry_id=client.id, unique_id=TEST_MAC,
minor_version=2,
) )
config_entry.add_to_hass(hass) 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() await hass.async_block_till_done()
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
entity_registry = er.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) 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 entity_entry is not None
assert device is not None assert device is not None