From 95c03b419287c3eda044ab7b2e9b453c0ee1644a Mon Sep 17 00:00:00 2001 From: "J.P. Krauss" Date: Mon, 28 Aug 2023 12:21:52 -0700 Subject: [PATCH] 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 * Incorporate review suggestions * Fix diagnostics test case * Apply suggestions from code review Co-authored-by: G Johansson * 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 --- homeassistant/components/airnow/__init__.py | 31 ++++++++- .../components/airnow/config_flow.py | 46 ++++++++++++- homeassistant/components/airnow/strings.json | 9 +++ tests/components/airnow/conftest.py | 13 +++- .../airnow/snapshots/test_diagnostics.ambr | 4 +- tests/components/airnow/test_config_flow.py | 68 ++++++++++++++++++- 6 files changed, 162 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 7c26cded4de..c4d52c6ac8e 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -47,7 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_key = entry.data[CONF_API_KEY] latitude = entry.data[CONF_LATITUDE] 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 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[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) 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: """Unload a config entry.""" 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 +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + class AirNowDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to hold Airly data.""" diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 67bce66e167..d72d145f7de 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -1,11 +1,12 @@ """Config flow for AirNow integration.""" import logging +from typing import Any from pyairnow import WebServiceAPI from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError 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.helpers.aiohttp_client import async_get_clientsession 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): """Handle a config flow for AirNow.""" - VERSION = 1 + VERSION = 2 async def async_step_user(self, user_input=None): """Handle the initial step.""" @@ -75,12 +76,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: # Create Entry + radius = user_input.pop(CONF_RADIUS) return self.async_create_entry( title=( f"AirNow Sensor at {user_input[CONF_LATITUDE]}," f" {user_input[CONF_LONGITUDE]}" ), data=user_input, + options={CONF_RADIUS: radius}, ) return self.async_show_form( @@ -94,12 +97,49 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): vol.Optional( CONF_LONGITUDE, default=self.hass.config.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, ) + @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): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index 9926a2f78aa..93ca14710b7 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -21,6 +21,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "options": { + "step": { + "init": { + "data": { + "radius": "Station Radius (miles)" + } + } + } + }, "entity": { "sensor": { "o3": { diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py index 15298ef3db0..4e9d1698e8c 100644 --- a/tests/components/airnow/conftest.py +++ b/tests/components/airnow/conftest.py @@ -12,13 +12,15 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config): +def config_entry_fixture(hass, config, options): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, + version=2, entry_id="3bd2acb0e4f0476d40865546d0d91921", unique_id=f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}", data=config, + options=options, ) entry.add_to_hass(hass) return entry @@ -31,7 +33,14 @@ def config_fixture(hass): CONF_API_KEY: "abc123", CONF_LATITUDE: 34.053718, 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, } diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index ca333bbff72..8041cb55692 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -21,19 +21,19 @@ 'api_key': '**REDACTED**', 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', - 'radius': 75, }), 'disabled_by': None, 'domain': 'airnow', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'options': dict({ + 'radius': 150, }), 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', 'title': '**REDACTED**', 'unique_id': '**REDACTED**', - 'version': 1, + 'version': 2, }), }) # --- diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index 5fda5f532a3..f62fc9aee22 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -6,10 +6,13 @@ import pytest from homeassistant import config_entries, data_entry_flow 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 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.""" result = await hass.config_entries.flow.async_init( 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) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["data"] == config + assert result2["options"] == options @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) assert result2["type"] == "abort" 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, + }