From 60fa02c042235cb14aee68b3b34186990393341f Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 10 Oct 2023 17:23:58 +0200 Subject: [PATCH] Bump pyOverkiz to 3.11 and migrate unique ids for select entries (#101024) * Bump pyOverkiz and migrate entries * Add comment * Remove entities when duplicate * Remove old entity * Remove old entities * Add example of entity migration * Add support of UIWidget and UIClass * Add tests for migrations * Apply feedback (1) * Apply feedback (2) --- homeassistant/components/overkiz/__init__.py | 66 +++++++++++++- .../components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/overkiz/test_init.py | 89 +++++++++++++++++++ 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 tests/components/overkiz/test_init.py diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index ab463fc34d9..6ca082ace76 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from aiohttp import ClientError, ServerDisconnectedError from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.enums import OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, @@ -17,9 +18,9 @@ from pyoverkiz.models import Device, Scenario from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -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_create_clientsession from .const import ( @@ -55,6 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: username=username, password=password, session=session, server=server ) + await _async_migrate_entries(hass, entry) + try: await client.login() @@ -144,3 +147,62 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Migrate old entries to new unique IDs.""" + entity_registry = er.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + # Python 3.11 treats (str, Enum) and StrEnum in a different way + # Since pyOverkiz switched to StrEnum, we need to rewrite the unique ids once to the new style + # + # io://xxxx-xxxx-xxxx/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL -> io://xxxx-xxxx-xxxx/3541212-core:DiscreteRSSILevelState + # internal://xxxx-xxxx-xxxx/alarm/0-UIWidget.TSKALARM_CONTROLLER -> internal://xxxx-xxxx-xxxx/alarm/0-TSKAlarmController + # io://xxxx-xxxx-xxxx/xxxxxxx-UIClass.ON_OFF -> io://xxxx-xxxx-xxxx/xxxxxxx-OnOff + if (key := entry.unique_id.split("-")[-1]).startswith( + ("OverkizState", "UIWidget", "UIClass") + ): + state = key.split(".")[1] + new_key = "" + + if key.startswith("UIClass"): + new_key = UIClass[state] + elif key.startswith("UIWidget"): + new_key = UIWidget[state] + else: + new_key = OverkizState[state] + + new_unique_id = entry.unique_id.replace(key, new_key) + + LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'. Entity will be removed", + new_unique_id, + existing_entity_id, + ) + entity_registry.async_remove(entry.entity_id) + + return None + + return { + "new_unique_id": new_unique_id, + } + + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + return True diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index d88996c7e02..4e1fdee989a 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.9.0"], + "requirements": ["pyoverkiz==1.11.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index e5eeaaa48f6..170081bd89f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1925,7 +1925,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.9.0 +pyoverkiz==1.11.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f08b72228d..6e5a34d83ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1450,7 +1450,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.9.0 +pyoverkiz==1.11.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py new file mode 100644 index 00000000000..774f3c9a79a --- /dev/null +++ b/tests/components/overkiz/test_init.py @@ -0,0 +1,89 @@ +"""Tests for Overkiz integration init.""" +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_HUB, TEST_PASSWORD + +from tests.common import MockConfigEntry, mock_registry + +ENTITY_SENSOR_DISCRETE_RSSI_LEVEL = "sensor.zipscreen_woonkamer_discrete_rssi_level" +ENTITY_ALARM_CONTROL_PANEL = "alarm_control_panel.alarm" +ENTITY_SWITCH_GARAGE = "switch.garage" +ENTITY_SENSOR_TARGET_CLOSURE_STATE = "sensor.zipscreen_woonkamer_target_closure_state" +ENTITY_SENSOR_TARGET_CLOSURE_STATE_2 = ( + "sensor.zipscreen_woonkamer_target_closure_state_2" +) + + +async def test_unique_id_migration(hass: HomeAssistant) -> None: + """Test migration of sensor unique IDs.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + mock_entry.add_to_hass(hass) + + mock_registry( + hass, + { + # This entity will be migrated to "io://1234-5678-1234/3541212-core:DiscreteRSSILevelState" + ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: er.RegistryEntry( + entity_id=ENTITY_SENSOR_DISCRETE_RSSI_LEVEL, + unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be migrated to "internal://1234-5678-1234/alarm/0-TSKAlarmController" + ENTITY_ALARM_CONTROL_PANEL: er.RegistryEntry( + entity_id=ENTITY_ALARM_CONTROL_PANEL, + unique_id="internal://1234-5678-1234/alarm/0-UIWidget.TSKALARM_CONTROLLER", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be migrated to "io://1234-5678-1234/0-OnOff" + ENTITY_SWITCH_GARAGE: er.RegistryEntry( + entity_id=ENTITY_SWITCH_GARAGE, + unique_id="io://1234-5678-1234/0-UIClass.ON_OFF", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be removed since "io://1234-5678-1234/3541212-core:TargetClosureState" already exists + ENTITY_SENSOR_TARGET_CLOSURE_STATE: er.RegistryEntry( + entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE, + unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_TARGET_CLOSURE", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will not be migrated" + ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: er.RegistryEntry( + entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE_2, + unique_id="io://1234-5678-1234/3541212-core:TargetClosureState", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + }, + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ent_reg = er.async_get(hass) + + unique_id_map = { + ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: "io://1234-5678-1234/3541212-core:DiscreteRSSILevelState", + ENTITY_ALARM_CONTROL_PANEL: "internal://1234-5678-1234/alarm/0-TSKAlarmController", + ENTITY_SWITCH_GARAGE: "io://1234-5678-1234/0-OnOff", + ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: "io://1234-5678-1234/3541212-core:TargetClosureState", + } + + # Test if entities will be removed + assert set(ent_reg.entities.keys()) == set(unique_id_map) + + # Test if unique ids are migrated + for entity_id, unique_id in unique_id_map.items(): + entry = ent_reg.async_get(entity_id) + assert entry.unique_id == unique_id