Add Options Flow to change radius after initial configuration (#97285)

* Add Options Flow to change radius after initial configuration

* Add tests for Options Flow

* Apply suggestions from code review

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Incorporate review suggestions

* Fix diagnostics test case

* Apply suggestions from code review

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Incorporate review suggestions

* Revert "Incorporate review suggestions"

This reverts commit 421e140a4fc78da22ea74c95cd1a17f9305ebbf6.

* Fix broken review comments

* Incorporate rest of review comments

* Incorporate rest of review comments

* Use Config Entry Migration

* Remove old migration code

* Update diagnostics snapshot for config entry migration

* Incorporate review feedback

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
J.P. Krauss 2023-08-28 12:21:52 -07:00 committed by GitHub
parent 9e8d89c4f5
commit 95c03b4192
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 162 additions and 9 deletions

View File

@ -47,7 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api_key = entry.data[CONF_API_KEY] api_key = entry.data[CONF_API_KEY]
latitude = entry.data[CONF_LATITUDE] latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE] longitude = entry.data[CONF_LONGITUDE]
distance = entry.data[CONF_RADIUS]
# Station Radius is a user-configurable option
distance = entry.options[CONF_RADIUS]
# Reports are published hourly but update twice per hour # Reports are published hourly but update twice per hour
update_interval = datetime.timedelta(minutes=30) update_interval = datetime.timedelta(minutes=30)
@ -65,11 +67,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator hass.data[DOMAIN][entry.entry_id] = coordinator
# Listen for option changes
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
new_options = {CONF_RADIUS: entry.data[CONF_RADIUS]}
new_data = entry.data.copy()
del new_data[CONF_RADIUS]
entry.version = 2
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options
)
_LOGGER.info("Migration to version %s successful", entry.version)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@ -80,6 +104,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
class AirNowDataUpdateCoordinator(DataUpdateCoordinator): class AirNowDataUpdateCoordinator(DataUpdateCoordinator):
"""Define an object to hold Airly data.""" """Define an object to hold Airly data."""

View File

@ -1,11 +1,12 @@
"""Config flow for AirNow integration.""" """Config flow for AirNow integration."""
import logging import logging
from typing import Any
from pyairnow import WebServiceAPI from pyairnow import WebServiceAPI
from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries, core, exceptions from homeassistant import config_entries, core, data_entry_flow, exceptions
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -48,7 +49,7 @@ async def validate_input(hass: core.HomeAssistant, data):
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for AirNow.""" """Handle a config flow for AirNow."""
VERSION = 1 VERSION = 2
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Handle the initial step.""" """Handle the initial step."""
@ -75,12 +76,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
# Create Entry # Create Entry
radius = user_input.pop(CONF_RADIUS)
return self.async_create_entry( return self.async_create_entry(
title=( title=(
f"AirNow Sensor at {user_input[CONF_LATITUDE]}," f"AirNow Sensor at {user_input[CONF_LATITUDE]},"
f" {user_input[CONF_LONGITUDE]}" f" {user_input[CONF_LONGITUDE]}"
), ),
data=user_input, data=user_input,
options={CONF_RADIUS: radius},
) )
return self.async_show_form( return self.async_show_form(
@ -94,12 +97,49 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
vol.Optional( vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude, ): cv.longitude,
vol.Optional(CONF_RADIUS, default=150): int, vol.Optional(CONF_RADIUS, default=150): vol.All(
int, vol.Range(min=5)
),
} }
), ),
errors=errors, errors=errors,
) )
@staticmethod
@core.callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Return the options flow."""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
"""Handle an options flow for AirNow."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
options_schema = vol.Schema(
{
vol.Optional(CONF_RADIUS): vol.All(
int,
vol.Range(min=5),
),
}
)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
options_schema, self.config_entry.options
),
)
class CannotConnect(exceptions.HomeAssistantError): class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect.""" """Error to indicate we cannot connect."""

View File

@ -21,6 +21,15 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
} }
}, },
"options": {
"step": {
"init": {
"data": {
"radius": "Station Radius (miles)"
}
}
}
},
"entity": { "entity": {
"sensor": { "sensor": {
"o3": { "o3": {

View File

@ -12,13 +12,15 @@ from tests.common import MockConfigEntry, load_fixture
@pytest.fixture(name="config_entry") @pytest.fixture(name="config_entry")
def config_entry_fixture(hass, config): def config_entry_fixture(hass, config, options):
"""Define a config entry fixture.""" """Define a config entry fixture."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
version=2,
entry_id="3bd2acb0e4f0476d40865546d0d91921", entry_id="3bd2acb0e4f0476d40865546d0d91921",
unique_id=f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}", unique_id=f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}",
data=config, data=config,
options=options,
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)
return entry return entry
@ -31,7 +33,14 @@ def config_fixture(hass):
CONF_API_KEY: "abc123", CONF_API_KEY: "abc123",
CONF_LATITUDE: 34.053718, CONF_LATITUDE: 34.053718,
CONF_LONGITUDE: -118.244842, CONF_LONGITUDE: -118.244842,
CONF_RADIUS: 75, }
@pytest.fixture(name="options")
def options_fixture(hass):
"""Define a config options data fixture."""
return {
CONF_RADIUS: 150,
} }

View File

@ -21,19 +21,19 @@
'api_key': '**REDACTED**', 'api_key': '**REDACTED**',
'latitude': '**REDACTED**', 'latitude': '**REDACTED**',
'longitude': '**REDACTED**', 'longitude': '**REDACTED**',
'radius': 75,
}), }),
'disabled_by': None, 'disabled_by': None,
'domain': 'airnow', 'domain': 'airnow',
'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921',
'options': dict({ 'options': dict({
'radius': 150,
}), }),
'pref_disable_new_entities': False, 'pref_disable_new_entities': False,
'pref_disable_polling': False, 'pref_disable_polling': False,
'source': 'user', 'source': 'user',
'title': '**REDACTED**', 'title': '**REDACTED**',
'unique_id': '**REDACTED**', 'unique_id': '**REDACTED**',
'version': 1, 'version': 2,
}), }),
}) })
# --- # ---

View File

@ -6,10 +6,13 @@ import pytest
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components.airnow.const import DOMAIN from homeassistant.components.airnow.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_form(hass: HomeAssistant, config, setup_airnow) -> None:
async def test_form(hass: HomeAssistant, config, options, setup_airnow) -> None:
"""Test we get the form.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -20,6 +23,7 @@ async def test_form(hass: HomeAssistant, config, setup_airnow) -> None:
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config)
assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result2["data"] == config assert result2["data"] == config
assert result2["options"] == options
@pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=InvalidKeyError)]) @pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=InvalidKeyError)])
@ -85,3 +89,65 @@ async def test_entry_already_exists(hass: HomeAssistant, config, config_entry) -
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config)
assert result2["type"] == "abort" assert result2["type"] == "abort"
assert result2["reason"] == "already_configured" assert result2["reason"] == "already_configured"
async def test_config_migration_v2(hass: HomeAssistant, setup_airnow) -> None:
"""Test that the config migration from Version 1 to Version 2 works."""
config_entry = MockConfigEntry(
version=1,
domain=DOMAIN,
title="AirNow",
data={
CONF_API_KEY: "1234",
CONF_LATITUDE: 33.6,
CONF_LONGITUDE: -118.1,
CONF_RADIUS: 25,
},
source=config_entries.SOURCE_USER,
options={CONF_RADIUS: 10},
unique_id="1234",
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.version == 2
assert not config_entry.data.get(CONF_RADIUS)
assert config_entry.options.get(CONF_RADIUS) == 25
async def test_options_flow(hass: HomeAssistant, setup_airnow) -> None:
"""Test that the options flow works."""
config_entry = MockConfigEntry(
version=2,
domain=DOMAIN,
title="AirNow",
data={
CONF_API_KEY: "1234",
CONF_LATITUDE: 33.6,
CONF_LONGITUDE: -118.1,
},
source=config_entries.SOURCE_USER,
options={CONF_RADIUS: 10},
unique_id="1234",
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_RADIUS: 25},
)
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert config_entry.options == {
CONF_RADIUS: 25,
}