From 5ef506623d8af24cb9eafe064fc82a78ef28487f Mon Sep 17 00:00:00 2001 From: MoonDevLT <107535193+MoonDevLT@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:07:45 +0200 Subject: [PATCH] Change attribute that is used as unique ID for lunatone (#165200) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/lunatone/__init__.py | 57 ++++++++++++++++++- .../components/lunatone/config_flow.py | 7 ++- homeassistant/components/lunatone/light.py | 26 +++++---- .../components/lunatone/strings.json | 3 +- tests/components/lunatone/__init__.py | 43 ++++++++++++-- tests/components/lunatone/conftest.py | 27 ++++++--- .../lunatone/snapshots/test_diagnostics.ambr | 2 +- .../lunatone/snapshots/test_light.ambr | 8 +-- tests/components/lunatone/test_config_flow.py | 14 +++-- tests/components/lunatone/test_init.py | 57 ++++++++++++++++++- 10 files changed, 202 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index 2e280168a86..c650fca92ec 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -1,5 +1,6 @@ """The Lunatone integration.""" +import logging from typing import Final from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info @@ -7,9 +8,10 @@ from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .config_flow import LunatoneConfigFlow from .const import DOMAIN, MANUFACTURER from .coordinator import ( LunatoneConfigEntry, @@ -18,9 +20,51 @@ from .coordinator import ( LunatoneInfoDataUpdateCoordinator, ) +_LOGGER = logging.getLogger(__name__) PLATFORMS: Final[list[Platform]] = [Platform.LIGHT] +async def _update_unique_id( + hass: HomeAssistant, entry: LunatoneConfigEntry, new_unique_id: str +) -> None: + _LOGGER.debug("Update unique ID") + + # Update all associated entities + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + for entity in entities: + parts = list(entity.unique_id.partition("-")) + parts[0] = new_unique_id + + entity_registry.async_update_entity( + entity.entity_id, new_unique_id="".join(parts) + ) + + # Update all associated devices + device_registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + + for device in devices: + identifier = device.identifiers.pop() + parts = list(identifier[1].partition("-")) + parts[0] = new_unique_id + + device_registry.async_update_device( + device.id, new_identifiers={(identifier[0], "".join(parts))} + ) + + # Update the config entry itself + hass.config_entries.async_update_entry( + entry, + unique_id=new_unique_id, + minor_version=LunatoneConfigFlow.MINOR_VERSION, + version=LunatoneConfigFlow.VERSION, + ) + + _LOGGER.debug("Update of unique ID successful") + + async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool: """Set up Lunatone from a config entry.""" auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) @@ -30,15 +74,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api) await coordinator_info.async_config_entry_first_refresh() - if info_api.serial_number is None: + if info_api.data is None or info_api.serial_number is None: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="missing_device_info" ) + if info_api.uid is not None: + new_unique_id = info_api.uid.replace("-", "") + if new_unique_id != entry.unique_id: + await _update_unique_id(hass, entry, new_unique_id) + + assert entry.unique_id + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, str(info_api.serial_number))}, + identifiers={(DOMAIN, entry.unique_id)}, name=info_api.name, manufacturer=MANUFACTURER, sw_version=info_api.version, diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py index b5004ffdce4..bb48361299e 100644 --- a/homeassistant/components/lunatone/config_flow.py +++ b/homeassistant/components/lunatone/config_flow.py @@ -52,14 +52,17 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): if info_api.serial_number is None: errors["base"] = "missing_device_info" else: - await self.async_set_unique_id(str(info_api.serial_number)) + unique_id = str(info_api.serial_number) + if info_api.uid is not None: + unique_id = info_api.uid.replace("-", "") + await self.async_set_unique_id(unique_id) if self.source == SOURCE_RECONFIGURE: self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates=data, title=url ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=url, data={CONF_URL: url}) + return self.async_create_entry(title=url, data=data) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index a733fd6588b..72243ae713a 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -41,17 +41,20 @@ async def async_setup_entry( coordinator_devices = config_entry.runtime_data.coordinator_devices dali_line_broadcasts = config_entry.runtime_data.dali_line_broadcasts + assert config_entry.unique_id is not None + entities: list[LightEntity] = [ LunatoneLineBroadcastLight( - coordinator_info, coordinator_devices, dali_line_broadcast + coordinator_info, + coordinator_devices, + dali_line_broadcast, + config_entry.unique_id, ) for dali_line_broadcast in dali_line_broadcasts ] entities.extend( [ - LunatoneLight( - coordinator_devices, device_id, coordinator_info.data.device.serial - ) + LunatoneLight(coordinator_devices, device_id, config_entry.unique_id) for device_id in coordinator_devices.data ] ) @@ -76,14 +79,14 @@ class LunatoneLight( self, coordinator: LunatoneDevicesDataUpdateCoordinator, device_id: int, - interface_serial_number: int, + config_entry_unique_id: str, ) -> None: """Initialize a Lunatone light.""" super().__init__(coordinator) self._device_id = device_id - self._interface_serial_number = interface_serial_number - self._device = self.coordinator.data[self._device_id] - self._attr_unique_id = f"{interface_serial_number}-device{device_id}" + self._config_entry_unique_id = config_entry_unique_id + self._device = self.coordinator.data[device_id] + self._attr_unique_id = f"{config_entry_unique_id}-device{device_id}" @property def device_info(self) -> DeviceInfo: @@ -94,7 +97,7 @@ class LunatoneLight( name=self._device.name, via_device=( DOMAIN, - f"{self._interface_serial_number}-line{self._device.data.line}", + f"{self._config_entry_unique_id}-line{self._device.data.line}", ), ) @@ -179,6 +182,7 @@ class LunatoneLineBroadcastLight( coordinator_info: LunatoneInfoDataUpdateCoordinator, coordinator_devices: LunatoneDevicesDataUpdateCoordinator, broadcast: DALIBroadcast, + config_entry_unique_id: str, ) -> None: """Initialize a Lunatone line broadcast light.""" super().__init__(coordinator_info) @@ -187,7 +191,7 @@ class LunatoneLineBroadcastLight( line = broadcast.line - self._attr_unique_id = f"{coordinator_info.data.device.serial}-line{line}" + self._attr_unique_id = f"{config_entry_unique_id}-line{line}" line_device = self.coordinator.data.lines[str(line)].device extra_info: dict = {} @@ -202,7 +206,7 @@ class LunatoneLineBroadcastLight( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, name=f"DALI Line {line}", - via_device=(DOMAIN, str(coordinator_info.data.device.serial)), + via_device=(DOMAIN, config_entry_unique_id), **extra_info, ) diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json index 1ba52be8e54..438d67782fb 100644 --- a/homeassistant/components/lunatone/strings.json +++ b/homeassistant/components/lunatone/strings.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index 0ddee686c94..12fd58b2a80 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -21,11 +21,12 @@ from tests.common import MockConfigEntry BASE_URL: Final = "http://10.0.0.131" PRODUCT_NAME: Final = "Test Product" SERIAL_NUMBER: Final = 12345 +UUID: Final = "be37ca9c-47c2-4498-a38b-c62c7c711840" VERSION: Final = "v1.14.1/1.4.3" DEVICE_INFO_DATA: Final[DeviceInfoData] = DeviceInfoData( - serial=SERIAL_NUMBER, + serial=12345, gtin=192837465, pcb="2a", articleNumber=87654321, @@ -35,6 +36,38 @@ DEVICE_INFO_DATA: Final[DeviceInfoData] = DeviceInfoData( INFO_DATA: Final[InfoData] = InfoData( name="Test", version=VERSION, + uid=UUID, + device=DEVICE_INFO_DATA, + lines={ + "0": DALIBusData( + sendBlockedInitialize=False, + sendBlockedQuiescent=False, + sendBlockedMacroRunning=False, + sendBufferFull=False, + lineStatus=LineStatus.OK, + device=DEVICE_INFO_DATA, + ), + "1": DALIBusData( + sendBlockedInitialize=False, + sendBlockedQuiescent=False, + sendBlockedMacroRunning=False, + sendBufferFull=False, + lineStatus=LineStatus.OK, + device=DeviceInfoData( + serial=54321, + gtin=101010101, + pcb="1a", + articleNumber=12345678, + productionYear=22, + productionWeek=10, + ), + ), + }, +) +LEGACY_INFO_DATA: Final[InfoData] = InfoData( + name="Test", + version=VERSION, + uid=None, device=DEVICE_INFO_DATA, lines={ "0": DALIBusData( @@ -96,10 +129,8 @@ def build_device_data_list() -> list[DeviceData]: ] -async def setup_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Set up the Lunatone integration for testing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index 2469633c17a..8d279bcb80a 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -3,13 +3,15 @@ from collections.abc import Generator from unittest.mock import AsyncMock, PropertyMock, patch -from lunatone_rest_api_client import Device, Devices +from lunatone_rest_api_client import Device, Devices, Info +from lunatone_rest_api_client.models import InfoData import pytest +from homeassistant.components.lunatone.config_flow import LunatoneConfigFlow from homeassistant.components.lunatone.const import DOMAIN from homeassistant.const import CONF_URL -from . import BASE_URL, INFO_DATA, PRODUCT_NAME, SERIAL_NUMBER, build_devices_data +from . import BASE_URL, INFO_DATA, PRODUCT_NAME, UUID, build_devices_data from tests.common import MockConfigEntry @@ -71,11 +73,18 @@ def mock_lunatone_info() -> Generator[AsyncMock]: ), ): info = mock_info.return_value - info.data = INFO_DATA - info.name = info.data.name - info.version = info.data.version - info.serial_number = info.data.device.serial - info.product_name = PRODUCT_NAME + + def _set_data(data: InfoData) -> Info: + info.data = data + info.name = info.data.name + info.product_name = PRODUCT_NAME + info.serial_number = info.data.device.serial + info.uid = info.data.uid + info.version = info.data.version + return info + + info.set_data = _set_data + info.set_data(INFO_DATA) yield info @@ -98,5 +107,7 @@ def mock_config_entry() -> MockConfigEntry: title=BASE_URL, domain=DOMAIN, data={CONF_URL: BASE_URL}, - unique_id=str(SERIAL_NUMBER), + unique_id=UUID.replace("-", ""), + version=LunatoneConfigFlow.VERSION, + minor_version=LunatoneConfigFlow.MINOR_VERSION, ) diff --git a/tests/components/lunatone/snapshots/test_diagnostics.ambr b/tests/components/lunatone/snapshots/test_diagnostics.ambr index 3298ba11076..ca291b9726f 100644 --- a/tests/components/lunatone/snapshots/test_diagnostics.ambr +++ b/tests/components/lunatone/snapshots/test_diagnostics.ambr @@ -178,7 +178,7 @@ 'node_red': False, 'startup_mode': 'normal', 'tier': 'basic', - 'uid': None, + 'uid': 'be37ca9c-47c2-4498-a38b-c62c7c711840', 'version': 'v1.14.1/1.4.3', }), }) diff --git a/tests/components/lunatone/snapshots/test_light.ambr b/tests/components/lunatone/snapshots/test_light.ambr index 45dc12c0e5f..a429bbf1de6 100644 --- a/tests/components/lunatone/snapshots/test_light.ambr +++ b/tests/components/lunatone/snapshots/test_light.ambr @@ -36,7 +36,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '12345-line0', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-line0', 'unit_of_measurement': None, }) # --- @@ -97,7 +97,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '12345-line1', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-line1', 'unit_of_measurement': None, }) # --- @@ -158,7 +158,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '12345-device1', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device1', 'unit_of_measurement': None, }) # --- @@ -217,7 +217,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '12345-device2', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-device2', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py index d3fbb684f54..f48c5179bf8 100644 --- a/tests/components/lunatone/test_config_flow.py +++ b/tests/components/lunatone/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock import aiohttp +from lunatone_rest_api_client.models import InfoData import pytest from homeassistant.components.lunatone.const import DOMAIN @@ -11,15 +12,21 @@ from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import BASE_URL +from . import BASE_URL, INFO_DATA, LEGACY_INFO_DATA from tests.common import MockConfigEntry +@pytest.mark.parametrize(("info_data"), [INFO_DATA, LEGACY_INFO_DATA]) async def test_full_flow( - hass: HomeAssistant, mock_lunatone_info: AsyncMock, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_setup_entry: AsyncMock, + info_data: InfoData, ) -> None: """Test full user flow.""" + mock_lunatone_info.set_data(info_data) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -37,8 +44,7 @@ async def test_full_flow( async def test_full_flow_fail_because_of_missing_device_infos( - hass: HomeAssistant, - mock_lunatone_info: AsyncMock, + hass: HomeAssistant, mock_lunatone_info: AsyncMock ) -> None: """Test full flow.""" mock_lunatone_info.serial_number = None diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py index be57b5802ee..b3073feca09 100644 --- a/tests/components/lunatone/test_init.py +++ b/tests/components/lunatone/test_init.py @@ -6,10 +6,11 @@ import aiohttp from homeassistant.components.lunatone.const import DOMAIN, MANUFACTURER from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import BASE_URL, PRODUCT_NAME, VERSION, setup_integration +from . import BASE_URL, PRODUCT_NAME, SERIAL_NUMBER, UUID, VERSION, setup_integration from tests.common import MockConfigEntry @@ -133,3 +134,55 @@ async def test_config_entry_not_ready_no_serial_number( mock_lunatone_info.async_update.assert_called_once() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_config_entry_unique_id_update( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the Lunatone config entry migration to be successful.""" + config_entry = MockConfigEntry( + title=BASE_URL, + domain=DOMAIN, + data={CONF_URL: BASE_URL}, + unique_id=str(SERIAL_NUMBER), + ) + + expected_unique_id = str(SERIAL_NUMBER) + mock_lunatone_info.uid = None + + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == expected_unique_id + + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + for device in devices: + for identifier in device.identifiers: + assert identifier[1].startswith(expected_unique_id) + + entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + for entity in entities: + assert entity.unique_id.startswith(expected_unique_id) + + expected_unique_id = UUID.replace("-", "") + mock_lunatone_info.uid = UUID + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == expected_unique_id + + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + for device in devices: + for identifier in device.identifiers: + assert identifier[1].startswith(expected_unique_id) + + entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + for entity in entities: + assert entity.unique_id.startswith(expected_unique_id)