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>
This commit is contained in:
MoonDevLT
2026-04-04 16:07:45 +02:00
committed by GitHub
parent 5632d308dd
commit 5ef506623d
10 changed files with 202 additions and 42 deletions
+54 -3
View File
@@ -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,
@@ -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,
+15 -11
View File
@@ -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,
)
@@ -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%]",
+37 -6
View File
@@ -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()
+19 -8
View File
@@ -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,
)
@@ -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',
}),
})
@@ -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,
})
# ---
+10 -4
View File
@@ -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
+55 -2
View File
@@ -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)