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:
@@ -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,
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user