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
This commit is contained in:
Aaron Bach 2022-12-19 10:48:36 -07:00 committed by GitHub
parent 7c13e7cdfd
commit 09ae02fd56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 192 additions and 25 deletions

View File

@ -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

View File

@ -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."""

View File

@ -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}."
}
}
}

View File

@ -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": {

View File

@ -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(

View File

@ -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(

View File

@ -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": {

View File

@ -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",
[