mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
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:
parent
7c13e7cdfd
commit
09ae02fd56
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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": {
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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": {
|
||||
|
@ -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",
|
||||
[
|
||||
|
Loading…
x
Reference in New Issue
Block a user