From 09ae02fd561e2bdb1555a83ce104cd7353912367 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 19 Dec 2022 10:48:36 -0700 Subject: [PATCH] Migrate AirVisual Pro devices to the `airvisual_pro` domain (#83882) * Migration AirVisual Pro devices to the `airvisual_pro` domain * Fix tests * Remove airvisual_pro dependency * Add repairs item * Only fire repairs issue if automations exist * Fix tests --- .../components/airvisual/__init__.py | 128 +++++++++++++++--- .../components/airvisual/config_flow.py | 3 +- .../components/airvisual/strings.json | 6 + .../components/airvisual/translations/en.json | 6 + .../components/airvisual_pro/config_flow.py | 4 + .../components/airvisual/test_config_flow.py | 53 +++++++- .../components/airvisual/test_diagnostics.py | 2 +- .../airvisual_pro/test_config_flow.py | 15 +- 8 files changed, 192 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 2a544edb20a..a01377f9ae5 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1,6 +1,7 @@ """The airvisual component.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from datetime import timedelta from math import ceil @@ -11,7 +12,8 @@ from pyairvisual.cloud_api import InvalidKeyError, KeyExpiredError, Unauthorized from pyairvisual.errors import AirVisualError from pyairvisual.node import NodeProError -from homeassistant.config_entries import ConfigEntry +from homeassistant.components import automation +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_IP_ADDRESS, @@ -27,9 +29,11 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import ( aiohttp_client, config_validation as cv, + device_registry as dr, entity_registry, ) from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -48,6 +52,10 @@ from .const import ( LOGGER, ) +# We use a raw string for the airvisual_pro domain (instead of importing the actual +# constant) so that we can avoid listing it as a dependency: +DOMAIN_AIRVISUAL_PRO = "airvisual_pro" + PLATFORMS = [Platform.SENSOR] DEFAULT_ATTRIBUTION = "Data provided by AirVisual" @@ -56,22 +64,6 @@ DEFAULT_NODE_PRO_UPDATE_INTERVAL = timedelta(minutes=1) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -@callback -def async_get_geography_id(geography_dict: Mapping[str, Any]) -> str: - """Generate a unique ID from a geography dict.""" - if CONF_CITY in geography_dict: - return ", ".join( - ( - geography_dict[CONF_CITY], - geography_dict[CONF_STATE], - geography_dict[CONF_COUNTRY], - ) - ) - return ", ".join( - (str(geography_dict[CONF_LATITUDE]), str(geography_dict[CONF_LONGITUDE])) - ) - - @callback def async_get_cloud_api_update_interval( hass: HomeAssistant, api_key: str, num_consumers: int @@ -108,6 +100,52 @@ def async_get_cloud_coordinators_by_api_key( ] +@callback +def async_get_geography_id(geography_dict: Mapping[str, Any]) -> str: + """Generate a unique ID from a geography dict.""" + if CONF_CITY in geography_dict: + return ", ".join( + ( + geography_dict[CONF_CITY], + geography_dict[CONF_STATE], + geography_dict[CONF_COUNTRY], + ) + ) + return ", ".join( + (str(geography_dict[CONF_LATITUDE]), str(geography_dict[CONF_LONGITUDE])) + ) + + +@callback +def async_get_pro_config_entry_by_ip_address( + hass: HomeAssistant, ip_address: str +) -> ConfigEntry: + """Get the Pro config entry related to an IP address.""" + [config_entry] = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN_AIRVISUAL_PRO) + if entry.data[CONF_IP_ADDRESS] == ip_address + ] + return config_entry + + +@callback +def async_get_pro_device_by_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Get the Pro device entry related to a config entry. + + Note that a Pro config entry can only contain a single device. + """ + device_registry = dr.async_get(hass) + [device_entry] = [ + device_entry + for device_entry in device_registry.devices.values() + if config_entry.entry_id in device_entry.config_entries + ] + return device_entry + + @callback def async_sync_geo_coordinator_update_intervals( hass: HomeAssistant, api_key: str @@ -311,6 +349,62 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) + # 2 -> 3: Moving AirVisual Pro to its own domain + elif version == 2: + version = 3 + + if entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_NODE_PRO: + ip_address = entry.data[CONF_IP_ADDRESS] + + # Get the existing Pro device entry before it is removed by the migration: + old_device_entry = async_get_pro_device_by_config_entry(hass, entry) + + new_entry_data = {**entry.data} + new_entry_data.pop(CONF_INTEGRATION_TYPE) + + tasks = [ + hass.config_entries.async_remove(entry.entry_id), + hass.config_entries.flow.async_init( + DOMAIN_AIRVISUAL_PRO, + context={"source": SOURCE_IMPORT}, + data=new_entry_data, + ), + ] + await asyncio.gather(*tasks) + + # If any automations are using the old device ID, create a Repairs issues + # with instructions on how to update it: + if device_automations := automation.automations_with_device( + hass, old_device_entry.id + ): + new_config_entry = async_get_pro_config_entry_by_ip_address( + hass, ip_address + ) + new_device_entry = async_get_pro_device_by_config_entry( + hass, new_config_entry + ) + + async_create_issue( + hass, + DOMAIN, + f"airvisual_pro_migration_{entry.entry_id}", + is_fixable=False, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="airvisual_pro_migration", + translation_placeholders={ + "ip_address": ip_address, + "old_device_id": old_device_entry.id, + "new_device_id": new_device_entry.id, + "device_automations_string": ", ".join( + f"`{automation}`" for automation in device_automations + ), + }, + ) + else: + entry.version = version + hass.config_entries.async_update_entry(entry) + LOGGER.info("Migration to version %s successful", version) return True diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 8ba75c43bdb..084124fa30f 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -64,7 +64,6 @@ PICK_INTEGRATION_TYPE_SCHEMA = vol.Schema( [ INTEGRATION_TYPE_GEOGRAPHY_COORDS, INTEGRATION_TYPE_GEOGRAPHY_NAME, - INTEGRATION_TYPE_NODE_PRO, ] ) } @@ -81,7 +80,7 @@ OPTIONS_FLOW = { class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an AirVisual config flow.""" - VERSION = 2 + VERSION = 3 def __init__(self) -> None: """Initialize the config flow.""" diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 317f91ef41a..7203fb22460 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -83,5 +83,11 @@ } } } + }, + "issues": { + "airvisual_pro_migration": { + "title": "{ip_address} is now part of the AirVisual Pro integration", + "description": "AirVisual Pro units are now their own Home Assistant integration (as opposed to be included with the original AirVisual integration that uses the AirVisual cloud API). The Pro device located at `{ip_address}` has automatically been migrated.\n\nAs part of that migration, the Pro's device ID has changed from `{old_device_id}` to `{new_device_id}`. Please update these automations to use the new device ID: {device_automations_string}." + } } } diff --git a/homeassistant/components/airvisual/translations/en.json b/homeassistant/components/airvisual/translations/en.json index 18029d7295e..04374cdcbab 100644 --- a/homeassistant/components/airvisual/translations/en.json +++ b/homeassistant/components/airvisual/translations/en.json @@ -74,6 +74,12 @@ } } }, + "issues": { + "airvisual_pro_migration": { + "description": "AirVisual Pro units are now their own Home Assistant integration (as opposed to be included with the original AirVisual integration that uses the AirVisual cloud API). The Pro device located at `{ip_address}` has automatically been migrated.\n\nAs part of that migration, the Pro's device ID has changed from `{old_device_id}` to `{new_device_id}`. Please update these automations to use the new device ID: {device_automations_string}.", + "title": "{ip_address} is now part of the AirVisual Pro integration" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/airvisual_pro/config_flow.py b/homeassistant/components/airvisual_pro/config_flow.py index 8fa588ec700..7cf03009932 100644 --- a/homeassistant/components/airvisual_pro/config_flow.py +++ b/homeassistant/components/airvisual_pro/config_flow.py @@ -67,6 +67,10 @@ class AirVisualProFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize.""" self._reauth_entry: ConfigEntry | None = None + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index 7603917eddc..e25204675f8 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -1,5 +1,5 @@ """Define tests for the AirVisual config flow.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from pyairvisual.cloud_api import ( InvalidKeyError, @@ -12,7 +12,7 @@ from pyairvisual.node import NodeProError import pytest from homeassistant import data_entry_flow -from homeassistant.components.airvisual.const import ( +from homeassistant.components.airvisual import ( CONF_CITY, CONF_COUNTRY, CONF_GEOGRAPHIES, @@ -32,6 +32,10 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, CONF_STATE, ) +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry @pytest.mark.parametrize( @@ -174,8 +178,8 @@ async def test_errors(hass, data, exc, errors, integration_type): ) ], ) -async def test_migration(hass, config, config_entry, setup_airvisual, unique_id): - """Test migrating from version 1 to the current version.""" +async def test_migration_1_2(hass, config, config_entry, setup_airvisual, unique_id): + """Test migrating from version 1 to 2.""" config_entries = hass.config_entries.async_entries(DOMAIN) assert len(config_entries) == 2 @@ -199,6 +203,47 @@ async def test_migration(hass, config, config_entry, setup_airvisual, unique_id) } +@pytest.mark.parametrize( + "config,config_entry_version,unique_id", + [ + ( + { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PASSWORD: "abcde12345", + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO, + }, + 2, + "192.16.1.100", + ) + ], +) +async def test_migration_2_3(hass, config, config_entry, unique_id): + """Test migrating from version 2 to 3.""" + with patch( + "homeassistant.components.airvisual.automation.automations_with_device", + return_value=["automation.test_automation"], + ), patch( + "homeassistant.components.airvisual.async_get_pro_config_entry_by_ip_address", + return_value=MockConfigEntry( + domain="airvisual_pro", + unique_id="192.168.1.100", + data={CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "abcde12345"}, + version=3, + ), + ), patch( + "homeassistant.components.airvisual.async_get_pro_device_by_config_entry", + return_value=Mock(id="abcde12345"), + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + airvisual_config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(airvisual_config_entries) == 0 + + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + async def test_options_flow(hass, config_entry): """Test config flow options.""" with patch( diff --git a/tests/components/airvisual/test_diagnostics.py b/tests/components/airvisual/test_diagnostics.py index 72ed5298f96..37a8437dcdf 100644 --- a/tests/components/airvisual/test_diagnostics.py +++ b/tests/components/airvisual/test_diagnostics.py @@ -9,7 +9,7 @@ async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airvisua assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { "entry": { "entry_id": config_entry.entry_id, - "version": 2, + "version": 3, "domain": "airvisual", "title": REDACTED, "data": { diff --git a/tests/components/airvisual_pro/test_config_flow.py b/tests/components/airvisual_pro/test_config_flow.py index 7ae9bbe44ab..32c4e14ba76 100644 --- a/tests/components/airvisual_pro/test_config_flow.py +++ b/tests/components/airvisual_pro/test_config_flow.py @@ -10,7 +10,7 @@ import pytest from homeassistant import data_entry_flow from homeassistant.components.airvisual_pro.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD @@ -61,6 +61,19 @@ async def test_duplicate_error(hass, config, config_entry): assert result["reason"] == "already_configured" +async def test_step_import(hass, config, setup_airvisual_pro): + """Test that the user step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.101" + assert result["data"] == { + CONF_IP_ADDRESS: "192.168.1.101", + CONF_PASSWORD: "password123", + } + + @pytest.mark.parametrize( "connect_mock,connect_errors", [